Files
OpenNest/OpenNest/Forms/CuttingPlanForm.cs
T

377 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Engine.CuttingPlanning;
namespace OpenNest.Forms;
/// <summary>
/// Plans part order and lead-ins for the active plate or every plate, previews the result and
/// applies it all-or-nothing through the cutting planner. Nothing live changes before Apply.
/// </summary>
public partial class CuttingPlanForm : Form
{
private readonly PlateView plateView;
private readonly Plate activePlate;
private readonly Plate[] plates;
private readonly int[] plateNumbers;
private readonly Func<bool> isOperationBusy;
private readonly string unit;
private readonly PlateView preview;
private readonly SynchronizationContext uiContext;
private readonly string previewText;
private CuttingParameters parameters;
private CancellationTokenSource planning;
private CuttingPlanProposal proposal;
private int generation;
private bool closeRequested;
/// <param name="plateView">The editor's view of the active plate.</param>
/// <param name="nest">The nest that owns the active plate.</param>
/// <param name="allPlates">Plan every plate that has parts instead of only the active plate.</param>
/// <param name="parameters">Cutting settings to start from; the form keeps its own copy.</param>
/// <param name="isOperationBusy">Reports a nesting or plate operation that must finish first.</param>
public CuttingPlanForm(PlateView plateView, Nest nest, bool allPlates, CuttingParameters parameters,
Func<bool> isOperationBusy = null)
{
this.plateView = plateView ?? throw new ArgumentNullException(nameof(plateView));
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(parameters);
// An owned copy: later edits to the caller's settings objects cannot change this plan.
this.parameters = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
activePlate = plateView.Plate ?? throw new ArgumentException("An active plate is required.", nameof(plateView));
var all = nest.Plates.ToList();
if (!all.Contains(activePlate))
throw new ArgumentException("The active plate is not in this nest.", nameof(nest));
plates = allPlates ? all.Where(p => p.Parts.Count > 0).ToArray() : new[] { activePlate };
if (plates.Length == 0)
plates = new[] { activePlate };
plateNumbers = plates.Select(p => all.IndexOf(p) + 1).ToArray();
this.isOperationBusy = isOperationBusy;
unit = UnitsHelper.GetShortString(nest.Units);
InitializeComponent();
// Captured once: when Application.DoEvents ends the outermost message loop, WinForms
// uninstalls its ambient context, so progress and results must not depend on whichever
// context is current when planning starts.
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
preview = new PlateView
{
Dock = DockStyle.Fill,
AllowSelect = false,
AllowDrop = false,
DrawOrigin = false,
DrawRapid = true,
Cursor = Cursors.Default,
TabStop = false,
Visible = false,
};
preview.SizeChanged += (_, _) =>
{
if (preview.Visible)
preview.ZoomToFit();
};
previewPanel.Controls.Add(preview);
if (allPlates)
{
Text = "Plan Cutting — All Plates";
applyButton.Text = "&Apply to All Plates";
}
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
// "of M" total; plateNumbers keep real collection positions for the batch summary.
var displayCount = PlateDisplayNumbering.DisplayedPlateCount(all);
var activeNumber = PlateDisplayNumbering.DisplayedPlateNumber(all, all.IndexOf(activePlate));
previewText = activeNumber == null
? "New plate (empty) has no parts; plates with parts are listed on the right."
: plates.Contains(activePlate)
? $"Plate {activeNumber} of {displayCount}, numbered in cutting order:"
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
previewLabel.Text = previewText;
ShowSettings();
}
/// <summary>The settings the operator confirmed; meaningful after an OK result.</summary>
public CuttingParameters ConfirmedParameters => parameters;
/// <summary>The commit outcome of the last Apply, or null if Apply never ran.</summary>
public CuttingCommitResult CommitResult { get; private set; }
internal Task PlanningTask { get; private set; } = Task.CompletedTask;
internal CuttingPlanProposal Proposal => proposal;
internal bool IsPlanning => planning != null;
/// <summary>Test seam: runs on the worker, before planning, with the run's cancellation token.</summary>
internal Action<CancellationToken> BeforePlan { get; set; }
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
// Min sizes and the splitter only fit once the container has its real size.
split.Panel1MinSize = 200;
split.Panel2MinSize = 220;
var widest = split.Width - split.Panel2MinSize - split.SplitterWidth;
if (widest > split.Panel1MinSize)
split.SplitterDistance = System.Math.Clamp(split.Width * 3 / 5, split.Panel1MinSize, widest);
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
StartPlanning();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (planning != null)
{
// Keep the editor disabled until the worker has stopped; then close for real.
e.Cancel = true;
closeRequested = true;
planning.Cancel();
statusLabel.Text = "Cancelling…";
cancelButton.Enabled = false;
}
base.OnFormClosing(e);
}
private bool TryCheckCanChange(out string reason)
{
reason = null;
if (isOperationBusy?.Invoke() == true || plateView.IsFillInProgress)
reason = "Wait for the current nesting or plate action to finish, then replan.";
else if (plateView.IsDisposed || !ReferenceEquals(plateView.Plate, activePlate))
reason = "The active plate changed. Close this dialog and try again.";
return reason == null;
}
private void StartPlanning()
{
if (planning != null || IsDisposed)
return; // One planning run at a time; inputs are disabled while it runs.
ClearProposal();
if (!TryCheckCanChange(out var reason))
{
ShowMessage(reason);
return;
}
CuttingPlanBatch batch;
try
{
batch = CuttingPlanBatch.Capture(plates, parameters, keepOrderCheckBox.Checked, plateNumbers);
}
catch (Exception ex)
{
// Capture runs inside the dialog's message loop: report a failure, never crash the editor.
ShowMessage($"Unable to start planning: {ex.Message}");
return;
}
var source = new CancellationTokenSource();
var token = source.Token;
planning = source;
var run = ++generation;
SetPlanning(true);
summaryBox.Text = string.Empty;
statusLabel.Text = "Planning…";
var progress = new PostingProgress(uiContext, value =>
{
if (run == generation && ReferenceEquals(planning, source) && !IsDisposed)
statusLabel.Text = ProgressText(value);
});
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
PlanningTask = completion.Task;
var beforePlan = BeforePlan;
Task.Run(() =>
{
beforePlan?.Invoke(token);
return batch.Plan(progress, token);
}).ContinueWith(
work => uiContext.Post(_ => FinishPlanning(work, source, run, completion), null),
CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
// Runs on the dialog's thread once the worker has stopped.
private void FinishPlanning(Task<CuttingPlanProposal> work, CancellationTokenSource source, int run,
TaskCompletionSource completion)
{
try
{
if (ReferenceEquals(planning, source))
planning = null;
source.Dispose();
if (run != generation || IsDisposed)
return;
SetPlanning(false);
if (closeRequested)
{
Close();
return;
}
if (work.IsFaulted)
ShowMessage($"Planning failed: {work.Exception.GetBaseException().Message}");
else if (work.IsCanceled)
ShowMessage("Planning was cancelled. Nothing has changed.");
else
ShowProposal(work.Result);
}
catch (Exception ex) when (!IsDisposed)
{
// A plan that cannot be presented must not leave a stale preview or an enabled Apply.
ShowMessage($"Unable to show the plan: {ex.Message}");
}
finally
{
completion.TrySetResult();
}
}
private void ShowProposal(CuttingPlanProposal result)
{
proposal = result;
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = result.RequiresWarningAcceptance;
statusLabel.Text = result.CanApply ? "Review the plan, then apply it."
: result.RequiresWarningAcceptance ? "Unverified plan: review and accept the warnings to apply."
: "Nothing can be applied.";
applyButton.Enabled = result.CanApply;
var index = Array.IndexOf(plates, activePlate);
var plate = index < 0 ? null : result.BuildPreview(index);
if (plate == null)
{
if (index >= 0)
previewLabel.Text = "No preview: this plate's plan is not ready. Part numbers in the summary "
+ "match the editor.";
return;
}
previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
preview.Plate = plate;
preview.Visible = true;
preview.ZoomToFit();
}
private void ClearProposal()
{
proposal = null;
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
preview.Visible = false;
previewLabel.Text = previewText;
}
private void ShowMessage(string message)
{
ClearProposal();
statusLabel.Text = string.Empty;
summaryBox.Text = message;
}
private void SetPlanning(bool running)
{
settingsButton.Enabled = !running;
keepOrderCheckBox.Enabled = !running;
acceptWarningsCheckBox.Enabled = !running;
planButton.Enabled = !running;
if (running)
applyButton.Enabled = false;
}
private static string ProgressText(CuttingPlanProgress progress)
{
var plate = progress.PlateCount == 1
? $"plate {progress.PlateNumber}"
: $"plate {progress.PlateNumber} ({progress.PlateIndex + 1} of {progress.PlateCount})";
return progress.Phase switch
{
CuttingPlanPhase.CheckingOverlap => $"Checking {plate} for overlapping parts…",
CuttingPlanPhase.Reordering => $"Planning {plate}: searching for a part order…",
_ => $"Planning {plate} with the current part order…",
};
}
private void ShowSettings()
{
var tabs = parameters.TabsEnabled ? "on" : "off";
settingsLabel.Text = $"Lead-ins: outside {Describe(parameters.ExternalLeadIn)}, holes "
+ $"{Describe(parameters.InternalLeadIn)}, arcs and circles {Describe(parameters.ArcCircleLeadIn)}. "
+ $"Tabs {tabs}.";
}
private static string Describe(LeadIn leadIn)
{
if (leadIn == null || leadIn is NoLeadIn)
return "none";
var name = leadIn.GetType().Name;
if (name.EndsWith("LeadIn", StringComparison.Ordinal))
name = name[..^"LeadIn".Length];
return Regex.Replace(name, "(?<=[a-z])(?=[A-Z])", " ").ToLowerInvariant();
}
private void SettingsButton_Click(object sender, EventArgs e)
{
using var dialog = new CuttingParametersDialog();
dialog.LoadParameters(parameters);
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
parameters = dialog.GetParameters();
ShowSettings();
StartPlanning();
}
private void KeepOrderCheckBox_CheckedChanged(object sender, EventArgs e) => StartPlanning();
private void PlanButton_Click(object sender, EventArgs e) => StartPlanning();
private sealed class PostingProgress(SynchronizationContext context, Action<CuttingPlanProgress> handler)
: IProgress<CuttingPlanProgress>
{
public void Report(CuttingPlanProgress value) => context.Post(_ => handler(value), null);
}
private bool CanApplyProposal => proposal?.CanApply == true
|| proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked;
private void AcceptWarningsCheckBox_CheckedChanged(object sender, EventArgs e) =>
applyButton.Enabled = planning == null && CanApplyProposal;
private void ApplyButton_Click(object sender, EventArgs e)
{
if (planning != null || !CanApplyProposal)
return;
if (!TryCheckCanChange(out var reason))
{
ShowMessage(reason);
return;
}
var commit = proposal.Apply(acceptWarningsCheckBox.Checked);
CommitResult = commit;
switch (commit.Status)
{
case CuttingCommitStatus.Applied:
DialogResult = DialogResult.OK;
Close();
return;
case CuttingCommitStatus.Stale:
ShowMessage("The nest changed after planning, so nothing was applied. Replan to continue.");
return;
default:
ShowMessage($"Nothing was applied: {commit.Message ?? commit.Status.ToString()}");
return;
}
}
}