Files
OpenNest/OpenNest/Forms/CuttingInspectionPanel.cs
T

686 lines
30 KiB
C#

using System;
using System.Drawing;
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.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
namespace OpenNest.Forms;
internal enum CuttingInspectionMode { Plan, Validate }
/// <summary>Owner-thread planning and atomic Apply beside the read-only main canvas.</summary>
internal sealed class CuttingInspectionPanel : UserControl
{
private readonly EditNestForm editor;
private readonly PlateView view;
private readonly Plate plate;
private CuttingParameters parameters;
private readonly Plate[] plates;
private readonly int[] plateNumbers;
private int selectedPlate;
private Plate preview;
private bool populatingWarnings;
private bool invalidated;
private readonly SynchronizationContext context;
private readonly (float Scale, PointF Origin) viewport;
private readonly OverlapDisplayMode overlapDisplay;
private CuttingProposalInspection inspection = new();
private readonly ComboBox modeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
private CancellationTokenSource validationCancellation;
private bool closeRequested;
private CuttingInspectionMode? pendingMode;
private bool configuringMode;
internal CuttingInspectionMode Mode { get; private set; }
internal PostVerificationReport ValidationReport { get; private set; }
internal Func<Nest, CancellationToken, Task<PostVerificationReport>> Analyze { get; set; }
= (nest, token) => Task.Run(() => PostVerificationAnalyzer.Analyze(nest, token), token);
private readonly Button planButton = new() { Text = "Replan", AutoSize = true };
private readonly Button cancelButton = new() { Text = "Cancel / Close", AutoSize = true };
private readonly TextBox resultLabel = new()
{
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
WordWrap = true,
MaxLength = int.MaxValue,
AccessibleName = "Cutting plan outcome and remedies"
};
private readonly Button settingsButton = new() { Text = "Cutting Settings…", AutoSize = true };
private readonly Button applyButton = new() { Text = "Apply", AutoSize = true, Enabled = false };
private readonly Button previousButton = new() { Text = "Previous", AutoSize = true };
private readonly Button nextButton = new() { Text = "Next", AutoSize = true };
private readonly CheckBox keepOrderCheckBox = new() { Text = "Keep current part order", AutoSize = true };
private readonly CheckBox acceptWarningsCheckBox = new() { Text = "Accept UNVERIFIED plan warnings (not posting consent)", AutoSize = true, Visible = false };
private readonly Label settingsLabel = new() { AutoSize = true, MaximumSize = new Size(440, 0) };
private readonly Label previewLabel = new() { AutoSize = true, MaximumSize = new Size(440, 0) };
private readonly Label validationScopeLabel = new()
{
AutoSize = true,
MaximumSize = new Size(440, 0),
Visible = false,
Text = "LIVE NEST only — not posting consent. Minimum spacing, plate boundaries and final post output are not checked."
};
private readonly DataGridView warningGrid = new()
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
MultiSelect = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
RowHeadersVisible = false,
AutoGenerateColumns = false
};
private ValidationInspectionScope scope;
private bool closed;
private int generation;
internal CuttingInspectionPanel(EditNestForm editor, CuttingParameters parameters, bool allPlates = false,
CuttingInspectionMode mode = CuttingInspectionMode.Plan)
{
this.editor = editor;
view = editor.PlateView;
plate = view.Plate;
var all = editor.Nest.Plates.ToList();
plates = allPlates ? all.Where(p => p.Parts.Count > 0).ToArray() : [plate];
if (plates.Length == 0)
plates = [plate];
plateNumbers = plates.Select(p => all.IndexOf(p) + 1).ToArray();
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, plate));
this.parameters = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
context = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
viewport = view.CaptureViewport();
overlapDisplay = view.OverlapDisplay;
var header = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, FlowDirection = FlowDirection.TopDown, WrapContents = false };
var buttons = new FlowLayoutPanel { AutoSize = true };
buttons.Controls.AddRange([settingsButton, planButton, applyButton, cancelButton]);
var navigation = new FlowLayoutPanel { AutoSize = true };
navigation.Controls.AddRange([previousButton, nextButton]);
modeSelector.Items.AddRange(["Plan Cutting", "Validate Nest (live programs)"]);
Mode = mode;
modeSelector.SelectedIndex = (int)mode;
modeSelector.SelectedIndexChanged += (_, _) =>
{
if (!configuringMode) SwitchMode((CuttingInspectionMode)modeSelector.SelectedIndex);
};
header.Controls.AddRange([modeSelector, settingsLabel, keepOrderCheckBox, buttons, acceptWarningsCheckBox, navigation, previewLabel, validationScopeLabel]);
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 };
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 30));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 70));
layout.Controls.Add(header, 0, 0);
layout.Controls.Add(resultLabel, 0, 1);
layout.Controls.Add(warningGrid, 0, 2);
Controls.Add(layout);
foreach (var name in new[] { "Plate", "Part", "With", "Warning / finding" })
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = name,
Width = 48,
SortMode = DataGridViewColumnSortMode.NotSortable,
AutoSizeMode = name == "Warning / finding" ? DataGridViewAutoSizeColumnMode.Fill : DataGridViewAutoSizeColumnMode.None
});
Padding = new Padding(10);
planButton.Click += (_, _) => StartCurrentMode();
cancelButton.Click += (_, _) => RequestClose();
settingsButton.Click += (_, _) => EditSettings();
keepOrderCheckBox.CheckedChanged += (_, _) => StartPlanning();
acceptWarningsCheckBox.CheckedChanged += (_, _) => UpdateApply();
applyButton.Click += (_, _) => Apply();
previousButton.Click += (_, _) => ShowPlate(selectedPlate - 1);
nextButton.Click += (_, _) => ShowPlate(selectedPlate + 1);
warningGrid.SelectionChanged += (_, _) => NavigateFinding();
ShowSettings();
resultLabel.Text = "Read-only proposal inspection. Nothing live changes before Apply.";
}
internal bool IsPlanning => inspection.IsRunning || validationCancellation != null;
internal Task PlanningTask { get; private set; } = Task.CompletedTask;
internal Action<CancellationToken> BeforePlan { get; set; }
internal Action BeforePresent { get; set; }
internal Action BeforePostApply { get; set; }
internal Action<Exception> OnPostApplyError { get; set; }
internal CuttingPlanProposal Proposal => inspection.Proposal;
internal void Open()
{
scope = new ValidationInspectionScope(editor, this, editor.SidePanelHeader, RequestClose);
view.InspectionCloseRequested = RequestClose;
view.OverlapDisplay = OverlapDisplayMode.Off;
view.ClearValidationFinding();
ConfigureMode();
StartCurrentMode();
}
private void StartCurrentMode()
{
if (Mode == CuttingInspectionMode.Validate) StartValidation();
else StartPlanning();
}
private void ConfigureMode()
{
configuringMode = true;
modeSelector.SelectedIndex = (int)Mode;
configuringMode = false;
var planning = Mode == CuttingInspectionMode.Plan;
settingsButton.Visible = settingsLabel.Visible = keepOrderCheckBox.Visible = applyButton.Visible = planning;
previousButton.Visible = nextButton.Visible = planning;
validationScopeLabel.Visible = !planning;
planButton.Text = planning ? "Replan" : "Recheck all plates";
previewLabel.Text = planning ? "No current preview." : "LIVE NEST — read-only validation of all plates.";
}
internal void SwitchMode(CuttingInspectionMode mode)
{
if (closed || closeRequested || mode == Mode && pendingMode == null) return;
pendingMode = mode;
ClearResult();
SetPlanning(true);
resultLabel.Text = "Switching modes… waiting for the worker.";
if (validationCancellation != null) validationCancellation.Cancel();
if (inspection.IsRunning) inspection.RequestClose();
if (!IsPlanning) FinishTransition();
}
private void FinishTransition()
{
if (closeRequested) { FinishClose(); return; }
if (pendingMode is not { } mode) return;
pendingMode = null;
RestoreLiveView();
inspection = new CuttingProposalInspection();
Mode = mode;
ConfigureMode();
StartCurrentMode();
}
private void RestoreLiveView()
{
view.ClearProposalDisplay();
view.ClearValidationFinding();
if (view.IsDisposed) return;
var index = editor.Nest.Plates.ToList().IndexOf(plate);
if (index >= 0 && !ReferenceEquals(view.Plate, plate)) editor.PlateManager.LoadAt(index);
view.RestoreViewport(viewport);
}
private void StartValidation()
{
if (closed || closeRequested || IsPlanning) return;
ClearResult();
ConfigureMode();
SetPlanning(true);
resultLabel.Text = "Checking all live plates: overlaps, missing lead-ins and rapid crossings…";
var cancellation = validationCancellation = new CancellationTokenSource();
var run = ++generation;
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
PlanningTask = completion.Task;
// The inspection scope keeps editors and posting from mutating these live inputs.
Task<PostVerificationReport> work;
try { work = Analyze(editor.Nest, cancellation.Token); }
catch (Exception ex) { work = Task.FromException<PostVerificationReport>(ex); }
work.ContinueWith(task => context.Post(_ =>
{
try
{
validationCancellation = null;
cancellation.Dispose();
if (closeRequested || pendingMode != null) { FinishTransition(); return; }
if (closed || run != generation) return;
SetPlanning(false);
if (!task.IsCompletedSuccessfully)
{
resultLabel.Text = task.IsCanceled ? "Validation cancelled. This nest has not passed the checks."
: $"Validation failed. This nest has not passed the checks. {task.Exception?.GetBaseException().Message}";
return;
}
ValidationReport = task.Result;
resultLabel.Text = ValidationReport.ToDisplayText("Nest validation (live programs)");
previewLabel.Text = ValidationReport.Findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? "Validation incomplete. Review the findings; this nest has not passed all checks."
: ValidationReport.HasWarnings ? "Warnings found. Review the findings below."
: "Validation complete. No warnings found by these checks.";
populatingWarnings = true;
try
{
foreach (var finding in ValidationReport.Findings)
{
var index = warningGrid.Rows.Add(finding.PlateNumber, finding.PartNumber, finding.OtherPartNumber,
$"{finding.Kind}: {finding.Message}");
warningGrid.Rows[index].Tag = finding;
}
warningGrid.ClearSelection();
}
finally { populatingWarnings = false; }
}
catch (Exception ex)
{
view.ClearValidationFinding();
resultLabel.Text = $"Validation failed. This nest has not passed the checks. {ex.Message}";
}
finally { completion.TrySetResult(); }
}, null), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
private void NavigateValidationFinding()
{
if (closed || closeRequested || pendingMode != null || populatingWarnings || IsPlanning || editor.IsDisposed)
return;
view.ClearValidationFinding();
if (warningGrid.SelectedRows.Count == 0 || warningGrid.SelectedRows[0].Tag is not PostVerificationFinding finding
|| finding.PlateNumber < 1 || finding.PlateNumber > editor.Nest.Plates.Count) return;
if (editor.PlateManager.CurrentIndex != finding.PlateNumber - 1)
editor.PlateManager.LoadAt(finding.PlateNumber - 1);
var location = finding.Location;
if (location == null && finding.PartNumber is { } part && part > 0 && part <= view.Plate.Parts.Count)
location = view.Plate.Parts[part - 1]?.BoundingBox?.Center;
if (location is { } point && double.IsFinite(point.X) && double.IsFinite(point.Y))
{
var extent = finding.Overlap?.Bounds;
var size = System.Math.Max(10, System.Math.Max(extent?.Length ?? 0, extent?.Width ?? 0) * 2);
if (finding.RapidStart is { } start && finding.RapidEnd is { } end)
size = System.Math.Max(size, start.DistanceTo(end) * 1.5);
view.ZoomToArea(point.X - size / 2, point.Y - size / 2, size, size);
// A narrow docked canvas can put a contact at its edge after zoom.
// Centre a typed witness, never infer one from the diagnostic text.
var focus = finding.Kind == PostVerificationKind.RapidCrossing
&& finding.ContactPoints.Any(contact => double.IsFinite(contact.X) && double.IsFinite(contact.Y))
? finding.ContactPoints.First(contact => double.IsFinite(contact.X) && double.IsFinite(contact.Y))
: point;
view.CenterValidationPoint(focus, new Point(view.Width / 2, view.Height / 2));
}
else view.ZoomToFit();
view.ShowValidationFinding(finding with { Location = location });
var hint = finding.Kind switch
{
PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.",
PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0
? "Crosshairs mark contacts with the completed contour. The pulsing dashed orange line shows the rapid."
: "The pulsing dashed orange line shows the rapid inside the completed contour; there is no boundary contact point to mark.",
PostVerificationKind.MissingLeadIn => "The ring marks the cutting contour start where a lead-in is missing.",
_ => "The ring marks the affected part's center when available; no exact problem location is known."
};
// Keep the complete report visible; selecting a finding does not hide completeness or other warnings.
resultLabel.Text = $"Plate {finding.PlateNumber}, part {finding.PartNumber}: {finding.Message}\r\n{hint}\r\n\r\n"
+ ValidationReport.ToDisplayText("Nest validation (live programs)");
}
internal void StartPlanning()
{
if (Mode != CuttingInspectionMode.Plan || closed || IsPlanning || closeRequested || inspection.CloseRequested)
return;
ClearResult();
SetPlanning(true);
resultLabel.Text = "Planning… Read-only; the live plate is unchanged.";
try
{
// Capture on the owner thread; no worker recapture of the live plate or settings.
var batch = CuttingPlanBatch.Capture(plates, parameters, keepOrderCheckBox.Checked, plateNumbers);
var run = ++generation;
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
PlanningTask = completion.Task;
var progress = new PostingProgress(context, value =>
{
if (!closed && !inspection.CloseRequested && inspection.IsRunning && generation == run)
resultLabel.Text = $"Planning plate {value.PlateNumber}: {value.Phase}…";
});
var work = inspection.BeginPlanning(batch, BeforePlan, progress);
work.ContinueWith(task => context.Post(_ => FinishPlanning(task, run, completion), null),
CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
catch (Exception ex)
{
resultLabel.Text = $"Unable to start planning: {ex.Message}. No current preview.";
SetPlanning(false);
}
}
private void FinishPlanning(Task<CuttingPlanProposal> work, int run, TaskCompletionSource completion)
{
try
{
inspection.CompletePlanning(work);
if (closeRequested || pendingMode != null)
{
FinishTransition();
return;
}
if (closed || run != generation)
return;
SetPlanning(false);
if (work.IsFaulted)
{
resultLabel.Text = $"Planning failed: {work.Exception.GetBaseException().Message}. No current preview.";
return;
}
if (work.IsCanceled)
{
resultLabel.Text = "Cancelled. No current preview; nothing changed.";
return;
}
invalidated = false;
resultLabel.Text = string.Join(Environment.NewLine, work.Result.Describe(UnitsHelper.GetShortString(editor.Nest.Units))
.Where(line => !line.TrimStart().StartsWith("- ", StringComparison.Ordinal)
&& !line.TrimStart().StartsWith("... and ", StringComparison.Ordinal)));
populatingWarnings = true;
try
{
foreach (var row in work.Result.DiagnosticRows())
{
var index = warningGrid.Rows.Add(row.PlateNumber, row.PartNumber, row.OtherPartNumber, row.Message);
warningGrid.Rows[index].Tag = row;
}
warningGrid.ClearSelection();
}
finally { populatingWarnings = false; }
acceptWarningsCheckBox.Visible = Proposal.RequiresWarningAcceptance;
UpdateApply();
BeforePresent?.Invoke();
ShowPlate(selectedPlate);
}
catch (Exception ex)
{
invalidated = true;
preview = null;
view.ClearProposalDisplay();
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
previewLabel.Text = "No current preview. Replan to continue.";
resultLabel.Text = $"Unable to show the plan: {ex.Message}. Nothing can be applied until replanning.";
SetPlanning(false);
}
finally
{
completion.TrySetResult();
}
}
private void ClearResult()
{
ValidationReport = null;
invalidated = true;
preview = null;
view.ClearProposalDisplay();
view.ClearValidationFinding();
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
previewLabel.Text = "No current preview.";
populatingWarnings = true;
try { warningGrid.Rows.Clear(); }
finally { populatingWarnings = false; }
}
private void SetPlanning(bool running)
{
settingsButton.Enabled = keepOrderCheckBox.Enabled = planButton.Enabled = !running;
previousButton.Enabled = !running && selectedPlate > 0;
nextButton.Enabled = !running && selectedPlate < plates.Length - 1;
acceptWarningsCheckBox.Enabled = !running;
if (running) applyButton.Enabled = false;
}
private void UpdateApply() => applyButton.Enabled = Mode == CuttingInspectionMode.Plan && !invalidated && !IsPlanning
&& plates.Any(item => item.Parts.Count > 0)
&& (Proposal?.CanApply == true || Proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked);
private bool IsPreviewCurrent()
{
if (!closed && !invalidated && ReferenceEquals(view.Plate, plates[selectedPlate])
&& inspection.RefreshPreview(selectedPlate) != null)
return true;
invalidated = true;
preview = null;
acceptWarningsCheckBox.Checked = false;
applyButton.Enabled = false;
resultLabel.Text = previewLabel.Text = "No current preview: the plate changed. Replan to inspect it. Nothing applied.";
return false;
}
private void ShowPlate(int index)
{
if (IsPlanning || invalidated || Proposal == null || index < 0 || index >= Proposal.Plates.Count)
return;
selectedPlate = index;
view.ClearProposalDisplay();
view.ClearValidationFinding();
// Keep the authoritative editor plate on the selected live source even when
// no safe detached proposal can be rendered for that plate.
var sourceIndex = editor.Nest.Plates.IndexOf(plates[index]);
if (sourceIndex < 0)
{
ClearResult();
resultLabel.Text = "The selected plate changed after capture. Replan to continue.";
return;
}
if (!ReferenceEquals(view.Plate, plates[index]))
editor.PlateManager.LoadAt(sourceIndex);
if (!ReferenceEquals(view.Plate, plates[index]))
{
ClearResult();
resultLabel.Text = "The selected plate is no longer current. Replan to continue.";
return;
}
if (plates[index].Parts.Count == 0)
{
preview = null;
previewLabel.Text = "New plate (empty)";
SetPlanning(false);
return;
}
preview = inspection.RefreshPreview(index); // Only the guarded authority may clone programs.
SetPlanning(false);
if (preview == null)
{
previewLabel.Text = $"Plate {plateNumbers[index]}: no current preview — stale, blocked or unsupported. Live plate shown; source numbers are not canvas locations.";
return;
}
previewLabel.Text = (Proposal.Plates[index].IsReady ? "PROPOSAL" : "UNVERIFIED PROPOSAL")
+ $" — Plate {plateNumbers[index]} ({index + 1} of {plates.Length}), numbered in proposed cutting order; read-only.";
view.ShowProposalDisplay(preview, IsPreviewCurrent);
view.ZoomToFit();
}
private void NavigateFinding()
{
if (Mode == CuttingInspectionMode.Validate)
{
NavigateValidationFinding();
return;
}
if (populatingWarnings || invalidated || warningGrid.SelectedRows.Count == 0
|| warningGrid.SelectedRows[0].Tag is not CuttingPlanDiagnosticRow row)
return;
ShowPlate(row.PlateIndex);
if (preview == null || row.PartNumber is not int source)
return;
var position = CuttingProposalInspection.ProposedPosition(Proposal, row.PlateIndex, source);
if (position == null)
return;
var bounds = preview.Parts[position.Value].BoundingBox;
if (row.OtherPartNumber is int otherSource
&& CuttingProposalInspection.ProposedPosition(Proposal, row.PlateIndex, otherSource) is int otherPosition)
{
var second = preview.Parts[otherPosition].BoundingBox;
var left = System.Math.Min(bounds.Left, second.Left);
var bottom = System.Math.Min(bounds.Bottom, second.Bottom);
bounds = new OpenNest.Geometry.Box(left, bottom,
System.Math.Max(bounds.Right, second.Right) - left,
System.Math.Max(bounds.Top, second.Top) - bottom);
}
if (double.IsFinite(bounds.X) && double.IsFinite(bounds.Y) && double.IsFinite(bounds.Length)
&& double.IsFinite(bounds.Width) && bounds.Length > 0 && bounds.Width > 0)
{
var pad = System.Math.Max(0.5, System.Math.Max(bounds.Length, bounds.Width) * 0.12);
view.ZoomToArea(bounds.X - pad, bounds.Y - pad, bounds.Length + 2 * pad, bounds.Width + 2 * pad);
}
previewLabel.Text += " Showing affected part extent, not an exact warning point.";
}
internal CuttingCommitResult CommitResult { get; private set; }
private void Apply()
{
if (!applyButton.Enabled || IsPlanning || invalidated || Proposal == null)
return;
// This handler runs on the owner thread; Apply independently rechecks all captured plates.
CommitResult = Proposal.Apply(acceptWarningsCheckBox.Checked);
if (CommitResult.Status == CuttingCommitStatus.Applied)
{
Exception postApplyError = null;
try
{
BeforePostApply?.Invoke();
editor.CompleteCuttingApply(parameters, CommitResult);
}
catch (Exception ex)
{
postApplyError = ex;
}
finally
{
RequestClose(); // The commit already succeeded; never strand the inspection lock.
}
if (postApplyError != null)
{
if (OnPostApplyError != null)
OnPostApplyError(postApplyError);
else
MessageBox.Show(editor, "The cutting plan was applied, but settings or the display did not refresh: "
+ postApplyError.Message, "Plan Cutting", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
return;
}
ClearResult();
resultLabel.Text = $"Nothing applied: {CommitResult.Message ?? CommitResult.Status.ToString()}. Replan to continue.";
}
private void EditSettings()
{
if (IsPlanning || closed) return;
using var dialog = new CuttingParametersDialog();
dialog.LoadParameters(parameters);
if (dialog.ShowDialog(this) != DialogResult.OK) return;
parameters = dialog.GetParameters();
ShowSettings();
StartPlanning();
}
private void ShowSettings()
{
settingsLabel.Text = $"Lead-ins: outside {Describe(parameters.ExternalLeadIn)}, holes {Describe(parameters.InternalLeadIn)}, arcs/circles {Describe(parameters.ArcCircleLeadIn)}. Tabs {(parameters.TabsEnabled ? "on" : "off")}.";
}
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();
}
internal void RequestClose()
{
if (closed)
return;
closeRequested = true;
pendingMode = null;
ClearResult();
SetPlanning(true);
cancelButton.Enabled = false;
resultLabel.Text = "Cancelling… waiting for the worker. Nothing has changed.";
validationCancellation?.Cancel();
inspection.RequestClose();
if (!IsPlanning)
FinishClose();
}
internal Action<Exception> OnRestoreError { get; set; }
private void FinishClose()
{
if (closed)
return;
closed = true;
Exception restoreError = null;
var ownedScope = scope;
scope = null;
try
{
RestoreLiveView();
if (!view.IsDisposed && ReferenceEquals(view.Plate, plate))
view.OverlapDisplay = overlapDisplay;
}
catch (Exception ex)
{
restoreError = ex;
}
finally
{
// Navigation can invoke arbitrary plate-change observers. Even if one throws,
// release the scope and panel so a stopped worker never strands editing.
if (!view.IsDisposed)
view.InspectionCloseRequested = null;
try { ownedScope?.Dispose(); }
catch (Exception ex) { restoreError ??= ex; }
try { editor.FinishCuttingInspection(this); }
catch (Exception ex) { restoreError ??= ex; }
try
{
if (!view.IsDisposed && ReferenceEquals(view.Plate, plate))
view.RestoreViewport(viewport);
}
catch (Exception ex) { restoreError ??= ex; }
try { ownedScope?.ResumeHostClose(); }
catch (Exception ex) { restoreError ??= ex; }
Dispose();
}
if (restoreError != null)
{
if (OnRestoreError != null)
OnRestoreError(restoreError);
else
MessageBox.Show(editor.IsDisposed ? null : editor,
"Inspection closed, but the original view could not be fully restored: " + restoreError.Message,
"Cutting Inspection", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
protected override void Dispose(bool disposing)
{
if (disposing && !closed)
{
RequestClose();
if (!closed)
return; // The worker still owns the inspection lock and controls.
}
base.Dispose(disposing);
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (keyData == Keys.Escape)
{
RequestClose();
return true;
}
return base.ProcessCmdKey(ref msg, keyData);
}
private sealed class PostingProgress(SynchronizationContext context, Action<CuttingPlanProgress> handler)
: IProgress<CuttingPlanProgress>
{
public void Report(CuttingPlanProgress value) => context.Post(_ => handler(value), null);
}
}