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