mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 08:21:05 -04:00
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:
1 parent
d3cb23f117
commit
78ca08efe1
9 files changed
+144
-13
No files matched your search
@@ -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) + "%";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user