diff --git a/OpenNest.Reporting/NestPdfWriter.cs b/OpenNest.Reporting/NestPdfWriter.cs
index a90fae5f..0319c127 100644
--- a/OpenNest.Reporting/NestPdfWriter.cs
+++ b/OpenNest.Reporting/NestPdfWriter.cs
@@ -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";
+
+ /// Whole seconds as "1h 02m 03s", "4m 05s" or "45s".
+ 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) + "%";
}
diff --git a/OpenNest.Reporting/NestReportBuilder.cs b/OpenNest.Reporting/NestReportBuilder.cs
index fa88bf9f..4e2fc7df 100644
--- a/OpenNest.Reporting/NestReportBuilder.cs
+++ b/OpenNest.Reporting/NestReportBuilder.cs
@@ -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
{
///
/// 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 ,
+ /// each plate also gets an estimated cut time from .
///
- 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();
@@ -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,
+ };
+ }
+
+ /// One copy of the plate: its cutting timeline plus clamp removal, as Calculate Cut Time.
+ 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;
diff --git a/OpenNest.Reporting/NestReportSnapshot.cs b/OpenNest.Reporting/NestReportSnapshot.cs
index b05bb28c..9f8172b7 100644
--- a/OpenNest.Reporting/NestReportSnapshot.cs
+++ b/OpenNest.Reporting/NestReportSnapshot.cs
@@ -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 Parts, ImmutableArray Cutoffs);
+ double Utilization, ImmutableArray Parts, ImmutableArray Cutoffs)
+{
+ /// Estimated time to cut one copy of this plate; null when no estimate was requested.
+ public TimeSpan? CutTime { get; init; }
+}
/// A detached value snapshot. No domain objects or mutable geometry are retained.
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 Drawings, ImmutableArray Plates)
{
public long TotalSheets => Plates.Aggregate(0L, (total, plate) => checked(total + plate.Copies));
+
+ /// True when the report carries estimated cut times.
+ public bool HasCutTimes { get; init; }
+
+ /// Every plate's estimate times its copies; null when the report has no estimates.
+ public TimeSpan? TotalCutTime => !HasCutTimes
+ ? null
+ : Plates.Aggregate(TimeSpan.Zero, (total, plate) => total + (plate.CutTime ?? TimeSpan.Zero) * plate.Copies);
}
diff --git a/OpenNest.Tests/Reporting/NestPdfWriterTests.cs b/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
index a818af72..58a5020e 100644
--- a/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
+++ b/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
@@ -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);
diff --git a/OpenNest.Tests/Reporting/NestReportBuilderTests.cs b/OpenNest.Tests/Reporting/NestReportBuilderTests.cs
index dec3a4f2..118b3fbb 100644
--- a/OpenNest.Tests/Reporting/NestReportBuilderTests.cs
+++ b/OpenNest.Tests/Reporting/NestReportBuilderTests.cs
@@ -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()
{
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 07e09530..7b04b725 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -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);
}
diff --git a/OpenNest/Forms/SavedCutParameters.cs b/OpenNest/Forms/SavedCutParameters.cs
index 09e4efb8..3987fba7 100644
--- a/OpenNest/Forms/SavedCutParameters.cs
+++ b/OpenNest/Forms/SavedCutParameters.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using OpenNest.Data;
namespace OpenNest.Forms
diff --git a/docs/cut-time-estimates.md b/docs/cut-time-estimates.md
index ece1f008..16a85628 100644
--- a/docs/cut-time-estimates.md
+++ b/docs/cut-time-estimates.md
@@ -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
diff --git a/docs/nest-reports.md b/docs/nest-reports.md
index a19ed535..8ba9c561 100644
--- a/docs/nest-reports.md
+++ b/docs/nest-reports.md
@@ -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