feat(reporting): show estimated cut time in nest reports

The nest report PDF gains an estimated cut time: the job total on the
summary, an Est. cut time column in the plates table, and per-plate and
all-copies times on each plate page. Times come from the cut simulation
with the settings last used in Calculate Cut Time, plus clamp removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 17:40:33 -04:00
1 parent d3cb23f117
commit 78ca08efe1
9 files changed
+144 -13

No files matched your search

+32 -1
View File
@@ -117,6 +117,8 @@ public static class NestPdfWriter
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));
if (snapshot.TotalCutTime is { } total)
AddInfoRow(info, "Estimated cut time:", Duration(total) + " (all sheets)");
if (snapshot.Notes.Length > 0)
{
// Free paragraphs split across pages; a table row taller than a page would be clipped.
@@ -127,7 +129,7 @@ public static class NestPdfWriter
AddHeading(section, "Plates");
if (snapshot.Plates.Length == 0)
section.AddParagraph("No plates in this job.");
else
else if (!snapshot.HasCutTimes)
{
var plates = AddTable(section, 60, 200, 80, 100, 100);
AddHeader(plates, "Plate", "Stock size", "Quantity", "Parts per plate", "Utilization");
@@ -135,6 +137,15 @@ public static class NestPdfWriter
AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
}
else
{
var plates = AddTable(section, 44, 150, 60, 96, 74, 116);
AddHeader(plates, "Plate", "Stock size", "Quantity", "Parts per plate", "Utilization", "Est. cut time");
foreach (var plate in snapshot.Plates)
AddRow(plates, $"Plate {plate.Number}", false, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization),
PlateCutTime(plate));
}
AddHeading(section, "Parts");
if (snapshot.Drawings.Length == 0)
@@ -179,6 +190,9 @@ public static class NestPdfWriter
$"{Number(plate.PartSpacing)} {snapshot.Units}");
AddInfoRow(info, "Parts per plate:", plate.Parts.Length.ToString(Invariant), "Utilization:",
Percent(plate.Utilization) + " (net part area / full sheet)");
if (plate.CutTime is { } cutTime)
AddInfoRow(info, "Est. cut time:", Duration(cutTime) + " per plate", "Total cut time:",
Duration(cutTime * plate.Copies) + (plate.Copies == 1 ? " (1 plate)" : $" ({plate.Copies} plates)"));
var overview = AddReservedArea(section, plan.Size.Height);
areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan)));
@@ -467,5 +481,22 @@ public static class NestPdfWriter
private static string Number(double value) => value.ToString("0.####", Invariant);
private static string PlateCutTime(ReportPlate plate) =>
plate.CutTime is not { } time ? ""
: plate.Copies == 1 ? Duration(time)
: $"{Duration(time)} each, {Duration(time * plate.Copies)} total";
/// <summary>Whole seconds as "1h 02m 03s", "4m 05s" or "45s".</summary>
private static string Duration(TimeSpan time)
{
var seconds = (long)System.Math.Round(time.TotalSeconds);
var hours = seconds / 3600;
var minutes = seconds / 60 % 60;
var rest = seconds % 60;
return hours > 0 ? $"{hours}h {minutes:00}m {rest:00}s"
: minutes > 0 ? $"{minutes}m {rest:00}s"
: $"{rest}s";
}
private static string Percent(double value) => (value * 100).ToString("0.0", Invariant) + "%";
}
+21 -4
View File
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Globalization;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Reporting;
@@ -8,9 +9,10 @@ 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.
/// domain cloning, or event-wired temporary plates are used. With <paramref name="cutParameters"/>,
/// each plate also gets an estimated cut time from <see cref="CutSimulation"/>.
/// </summary>
public static NestReportSnapshot Capture(Nest nest, DateTimeOffset generatedAt)
public static NestReportSnapshot Capture(Nest nest, DateTimeOffset generatedAt, CutParameters? cutParameters = null)
{
ArgumentNullException.ThrowIfNull(nest);
var ordered = new List<DrawingCount>();
@@ -88,7 +90,10 @@ public static class NestReportBuilder
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()));
utilization, parts.ToImmutable(), cutoffs.ToImmutable())
{
CutTime = cutParameters == null ? null : EstimateCutTime(plate, cutParameters, number),
});
}
// OrderBy is stable, so the original enumeration breaks equal name/path ties.
@@ -122,7 +127,19 @@ public static class NestReportBuilder
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());
drawings.ToImmutable(), plates.ToImmutable())
{
HasCutTimes = cutParameters != null,
};
}
/// <summary>One copy of the plate: its cutting timeline plus clamp removal, as Calculate Cut Time.</summary>
private static TimeSpan EstimateCutTime(Plate plate, CutParameters parameters, int number)
{
var time = CutSimulation.Build(plate, parameters).TotalTime + TimeSpan.FromMinutes(plate.ClampRemovalMinutes);
if (time < TimeSpan.Zero)
throw new InvalidOperationException($"Plate {number}: estimated cut time is invalid.");
return time;
}
private static bool PositiveFinite(double value) => double.IsFinite(value) && value > 0;
+13 -1
View File
@@ -27,7 +27,11 @@ public sealed record ReportDrawing(string Id, string Name, long Required, long N
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);
double Utilization, ImmutableArray<ReportPlacement> Parts, ImmutableArray<ReportGeometry> Cutoffs)
{
/// <summary>Estimated time to cut one copy of this plate; null when no estimate was requested.</summary>
public TimeSpan? CutTime { get; init; }
}
/// <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,
@@ -35,4 +39,12 @@ public sealed record NestReportSnapshot(string Name, string Customer, string Not
ImmutableArray<ReportDrawing> Drawings, ImmutableArray<ReportPlate> Plates)
{
public long TotalSheets => Plates.Aggregate(0L, (total, plate) => checked(total + plate.Copies));
/// <summary>True when the report carries estimated cut times.</summary>
public bool HasCutTimes { get; init; }
/// <summary>Every plate's estimate times its copies; null when the report has no estimates.</summary>
public TimeSpan? TotalCutTime => !HasCutTimes
? null
: Plates.Aggregate(TimeSpan.Zero, (total, plate) => total + (plate.CutTime ?? TimeSpan.Zero) * plate.Copies);
}
@@ -207,6 +207,24 @@ public sealed class NestPdfWriterTests : IDisposable
public void Dispose() => Directory.Delete(directory, true);
[Fact]
public void Write_CutTimeEstimateAppearsOnSummaryAndPlatePages()
{
var path = Path.Combine(directory, "timed.report.pdf");
var parameters = new CutParameters { Feedrate = 100, RapidTravelRate = 600, Units = Units.Inches };
NestPdfWriter.Write(NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt,
parameters), path);
var summary = ReportPdf.Text(path, 1);
var plate = ReportPdf.Text(path, 2);
Assert.Contains("Estimated cut time:", summary);
Assert.DoesNotContain("basis", summary, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Est. cut time", summary);
Assert.Contains("per plate", plate);
Assert.Contains("Total cut time:", plate);
}
private string WriteReport(Nest nest, string name = "test.report.pdf")
{
var path = Path.Combine(directory, name);
@@ -8,6 +8,45 @@ namespace OpenNest.Tests.Reporting;
public class NestReportBuilderTests
{
[Fact]
public void Capture_WithoutCutTimeLeavesEstimatesOut()
{
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
Assert.False(snapshot.HasCutTimes);
Assert.Null(snapshot.TotalCutTime);
Assert.All(snapshot.Plates, plate => Assert.Null(plate.CutTime));
}
[Fact]
public void Capture_WithCutTimeEstimatesEachPlateLikeCalculateCutTime()
{
var nest = NestReportTestData.CreateMultiPlateNest();
var parameters = new CutParameters
{
Feedrate = 100,
HoleFeedrate = 60,
RapidTravelRate = 600,
PierceTime = TimeSpan.FromSeconds(1),
Units = Units.Inches,
Motion = new MotionProfile { CutAcceleration = 100, RapidAcceleration = 200, CornerDeviation = 0.001 },
};
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt, parameters);
Assert.True(snapshot.HasCutTimes);
var expectedTotal = TimeSpan.Zero;
foreach (var plate in snapshot.Plates)
{
var source = nest.Plates[plate.Number - 1];
Assert.Equal(CutSimulation.Build(source, parameters).TotalTime, plate.CutTime);
Assert.True(plate.CutTime > TimeSpan.Zero);
expectedTotal += plate.CutTime!.Value * plate.Copies;
}
Assert.Equal(expectedTotal, snapshot.TotalCutTime);
Assert.Equal(Timing.CalculateTime(nest, parameters), snapshot.TotalCutTime);
}
[Fact]
public void Capture_ReconcilesOrderedReferenceUnionWithoutCountingCutoffs()
{
+5 -1
View File
@@ -714,7 +714,11 @@ namespace OpenNest.Forms
// Reporting must have no accounting side effects: no quantity refresh, no
// selection or dirty-state change. Only the detached snapshot reaches the writer.
var snapshot = NestReportBuilder.Capture(targets.Nest, DateTimeOffset.Now);
var snapshot = NestReportBuilder.Capture(
targets.Nest,
DateTimeOffset.Now,
SavedCutParameters.Load(targets.Nest.Units)
);
NestPdfWriter.Write(snapshot, destination);
}
-1
View File
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using OpenNest.Data;
namespace OpenNest.Forms
+3 -3
View File
@@ -1,8 +1,8 @@
# Cut-time estimates
Calculate Cut Time and Plate > Simulate Cutting share one timing model
(`OpenNest.Core/CNC/CutSimulation.cs`, `ContourTiming.cs`, `MotionPlanner.cs`), and the
last-used settings are shared through `SavedCutParameters`.
Calculate Cut Time, Plate > Simulate Cutting and the [nest report](nest-reports.md) share one
timing model (`OpenNest.Core/CNC/CutSimulation.cs`, `ContourTiming.cs`, `MotionPlanner.cs`),
and the last-used settings are shared through `SavedCutParameters`.
## Inputs
+13 -2
View File
@@ -95,8 +95,19 @@ calling the library. PDFsharp/MigraDoc layout and font state is process-wide;
`NestPdfWriter.Write` serializes every export behind one static lock so
concurrent calls cannot lay out text differently from a sequential export.
Advanced timing, cutting distances, pierce counts, weights, costs, gas use, and
machine/NC identity are intentionally omitted until their semantics are verified.
## Estimated cut time
When the caller passes `CutParameters` (the desktop export always does, from the
settings last used in Calculate Cut Time or the simulator), each plate gets an
estimated time per copy from `CutSimulation`, plus clamp-removal minutes, the same
as Calculate Cut Time (see [cut-time estimates](cut-time-estimates.md)). The summary
shows the job total over all sheets, and the plates table gains an "Est. cut time"
column. Each plate page shows the time per plate and for all its copies. The report
does not state the settings behind the estimate. These are estimates, not
machine-reported times. Without `CutParameters` the report has no timing rows or column.
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.
## Backend, fonts and redistribution