Files
OpenNest/OpenNest/Controls/OverlapOverlayController.cs
T

626 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Collections;
using OpenNest.Diagnostics;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.Controls;
/// <summary>
/// UI-thread owner of overlap requests and their cached display geometry.
/// Workers see only owned analyzer snapshots, never the live plate or view.
/// When auto-check is enabled, an unchecked or out-of-date layout is rechecked after it
/// has been quiet for <see cref="AutoCheckDelay"/> (see <see cref="OverlapAutoCheckScheduler"/>).
/// </summary>
internal sealed class OverlapOverlayController : IDisposable
{
private readonly PlateView view;
private readonly OverlapReportState state = new();
private readonly OverlapAutoCheckScheduler autoCheck = new();
// Prepared drawing material survives rechecks; only in-place program edits clear it.
private readonly OverlapMaterialCache materialCache = new();
// Last completed report: pairs of parts unchanged since then are reused, not clipped again.
private PlateOverlapReport baseline;
private readonly System.Windows.Forms.Timer autoCheckTimer;
private Func<Units> autoCheckUnits;
private ObservableList<Part> observedParts;
private CancellationTokenSource cancellation;
private GraphicsPath path;
private IReadOnlyList<PlateOverlapPair> pathPairs;
private float pathScale;
private Units capturedUnits;
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
private string hoverText;
private OverlapHoverPages hoverPages;
private Point hoverPoint;
private (float Scale, PointF Offset, int Dpi) hoverTransform;
private bool disposed;
public OverlapOverlayController(PlateView view)
{
this.view = view;
autoCheckTimer = new System.Windows.Forms.Timer { Interval = DefaultAutoCheckDelayMs };
autoCheckTimer.Tick += AutoCheckTick;
}
public const int DefaultAutoCheckDelayMs = 500;
public event EventHandler StateChanged;
public OverlapCheckStatus Status => state.Status;
public PlateOverlapReport Report => state.Report;
public bool IsRunning => state.IsRunning;
// Deterministic worker seam for STA lifecycle tests. Capture always stays on the UI thread.
// Null runs the incremental analyzer against the last completed report.
internal Func<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> Analyze { get; set; }
internal GraphicsPath CachedPath => path;
internal OverlapHoverPages HoverPages => hoverPages;
internal RectangleF HoverBounds { get; private set; }
internal bool IsAutoCheckEnabled => autoCheckUnits != null;
internal bool IsAutoCheckScheduled => autoCheckTimer.Enabled;
// Test seam for gestures that cannot be simulated off a real message loop.
internal Func<bool> IsInteractionActive { get; set; } = DefaultInteractionActive;
public int AutoCheckDelay
{
get => autoCheckTimer.Interval;
set => autoCheckTimer.Interval = value;
}
public string Message => autoCheck.IsWaiting
? "Overlaps: check pending…"
: state.Message;
/// <summary>
/// Recheck automatically after layout edits settle. Units are read when each request
/// starts, like the manual command. Pass null to return to manual-only checks.
/// </summary>
public void SetAutoCheck(Func<Units> units)
{
autoCheckUnits = units;
autoCheck.Reset();
autoCheckTimer.Stop();
NotifyChanged();
}
public OverlapDisplayMode DisplayMode
{
get => state.DisplayMode;
set
{
if (state.DisplayMode == value)
return;
state.DisplayMode = value;
NotifyChanged();
}
}
public void SetPlate(Plate plate)
{
Unsubscribe();
CancelWorker();
state.Reset();
autoCheck.Reset();
baseline = null;
ReleasePath();
observedParts = plate?.Parts;
if (observedParts != null)
{
observedParts.ItemAdded += PartAdded;
observedParts.ItemRemoved += PartRemoved;
observedParts.ItemChanged += PartChanged;
}
NotifyChanged();
}
public Task CheckAsync(Units units) => CheckAsync(units, automatic: false);
// Automatic requests keep the display mode and report only on the canvas label; the
// status bar keeps the user's last command result (for example a fill's timing).
private async Task CheckAsync(Units units, bool automatic)
{
if (!CanUseView || view.Plate == null)
return;
if (view.InvokeRequired)
throw new InvalidOperationException("Overlap checks must be started on the UI thread.");
CancelWorker();
var plate = view.Plate;
var generation = state.Begin(plate, automatic);
autoCheck.Started(plate);
capturedUnits = units; // Same request boundary as the owned geometry/names, never read live units in paint.
var source = new CancellationTokenSource();
cancellation = source;
var token = source.Token;
NotifyChanged(toStatusBar: !automatic);
try
{
// Do not include PreviewManager parts. Capture owns clean geometry and stable names.
var snapshot = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), materialCache, token);
var previous = baseline;
var analyze = Analyze ?? ((input, cancel) => PlateOverlapAnalyzer.Analyze(input, previous, cancel));
var report = await Task.Run(() => analyze(snapshot, token), token);
if (!CanUseView || generation != state.Generation || !ReferenceEquals(plate, view.Plate))
return;
if (token.IsCancellationRequested)
state.Cancel();
else if (state.TryPublish(generation, view.Plate, report))
baseline = report;
else
CancelWorker(); // A pose/reference mismatch discovered at publication is stale.
NotifyChanged(toStatusBar: !automatic);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
if (CanUseView && generation == state.Generation)
{
state.Cancel();
NotifyChanged(toStatusBar: !automatic);
}
}
catch (Exception ex)
{
Debug.WriteLine($"Overlap check failed: {ex}");
if (CanUseView && generation == state.Generation)
{
if (!state.TryFail(generation, view.Plate))
CancelWorker();
NotifyChanged(toStatusBar: !automatic);
}
}
finally
{
if (ReferenceEquals(cancellation, source))
cancellation = null;
source.Dispose();
}
}
private bool CanUseView => !disposed && !view.IsDisposed && !view.Disposing && view.IsHandleCreated;
public void Cancel()
{
if (!state.IsRunning)
return;
CancelWorker();
state.Cancel();
ReleasePath();
NotifyChanged(toStatusBar: true); // The user's Cancel command.
}
/// <summary>
/// For editors that may mutate clean drawing programs in place: drops cached material
/// as well as the current report. Call before loading the editor.
/// </summary>
public void InvalidateGeometry()
{
materialCache.Clear();
baseline = null;
state.Invalidate();
CancelWorker();
ReleasePath();
NotifyChanged();
}
public void Invalidate()
{
var generation = state.Generation;
var display = state.DisplayPairs;
state.Invalidate(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation == state.Generation)
{
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
// event per part, so only restart the timer here; the next paint or the timer's own
// settle check refreshes the layout stamp.
if (autoCheck.IsWaiting)
RestartAutoCheckTimer();
else
UpdateAutoCheck(repaint: true);
return;
}
CancelWorker();
NotifyChanged();
}
public void ReleaseHandle()
{
CancelWorker();
state.Cancel();
state.Invalidate();
ReleasePath();
// Handle loss is not a user cancel: forget that request so the first paint after
// recreation can schedule a recheck. A tick while the handle is gone only resets.
autoCheck.Reset();
autoCheckTimer.Stop();
NotifyChanged();
}
private void CancelWorker()
{
// The awaiting request disposes its own source after the worker exits.
var source = cancellation;
cancellation = null;
source?.Cancel();
}
private void NotifyChanged(bool toStatusBar = false)
{
if (!ReferenceEquals(pathPairs, state.DisplayPairs))
ReleasePath();
ClearHover();
if (disposed || view.IsDisposed || view.Disposing)
return;
UpdateAutoCheck(repaint: false);
// Without auto-check every change is reported as before.
if (toStatusBar || autoCheckUnits == null)
view.Status = Message;
view.Invalidate();
StateChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Restart the quiet period when the layout changed since it was last observed.
/// <paramref name="repaint"/> redraws the label if its pending text changed; paint and
/// NotifyChanged callers draw or invalidate themselves.
/// </summary>
private void UpdateAutoCheck(bool repaint)
{
if (autoCheckUnits == null || disposed)
return;
var wasWaiting = autoCheck.IsWaiting;
if (autoCheck.Observe(view.Plate, state))
RestartAutoCheckTimer();
else if (!autoCheck.IsWaiting)
autoCheckTimer.Stop();
if (repaint && wasWaiting != autoCheck.IsWaiting && !view.IsDisposed && !view.Disposing)
view.Invalidate();
}
private void RestartAutoCheckTimer()
{
autoCheckTimer.Stop();
autoCheckTimer.Start();
}
private void AutoCheckTick(object sender, EventArgs e) => RunAutoCheckTimer();
/// <summary>The timer's tick. Returns the started request, if any, for STA tests.</summary>
internal Task RunAutoCheckTimer()
{
autoCheckTimer.Stop();
if (autoCheckUnits == null || disposed)
return Task.CompletedTask;
if (!CanUseView)
{
autoCheck.Reset();
return Task.CompletedTask;
}
var busy = view.IsFillInProgress || IsInteractionActive();
switch (autoCheck.Elapsed(view.Plate, state, busy))
{
case OverlapAutoCheckStep.Wait:
autoCheckTimer.Start();
return Task.CompletedTask;
case OverlapAutoCheckStep.Check:
// CheckAsync reports its own failures through the state label.
return CheckAsync(autoCheckUnits(), automatic: true);
default:
view.Invalidate(); // Pending text may have cleared.
return Task.CompletedTask;
}
}
// Do not check mid-gesture or while another operation owns the plate: dragging holds a
// mouse button, fills/auto-nest show a progress window, and modal editors (for example
// the drawing converter) may be mutating clean programs in place.
private static bool DefaultInteractionActive() =>
System.Windows.Forms.Control.MouseButtons != System.Windows.Forms.MouseButtons.None
|| System.Windows.Forms.Application.OpenForms.Cast<System.Windows.Forms.Form>()
.Any(form => form.Modal || form is NestProgressForm);
private void PartAdded(object sender, ItemAddedEventArgs<Part> e) => Invalidate();
private void PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => Invalidate();
private void PartChanged(object sender, ItemChangedEventArgs<Part> e) => Invalidate();
private void Unsubscribe()
{
if (observedParts == null)
return;
observedParts.ItemAdded -= PartAdded;
observedParts.ItemRemoved -= PartRemoved;
observedParts.ItemChanged -= PartChanged;
observedParts = null;
}
public void Draw(Graphics graphics)
{
if (disposed)
return;
EnsureFresh();
UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
ValidateHoverTransform();
if (state.DisplayPairs.Count > 0)
{
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
{
EnsurePath();
if (path.PointCount > 0)
{
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
// A single winding fill is the union: triple overlap is not painted darker.
graphics.FillPath(brush, path);
}
}
if (ShowsCentroids)
DrawCentroids(graphics);
}
DrawStateLabel(graphics);
}
private bool EnsureFresh()
{
if (disposed)
return false;
var generation = state.Generation;
var display = state.DisplayPairs;
var fresh = state.EnsureFresh(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation != state.Generation)
{
CancelWorker();
NotifyChanged();
}
return fresh;
}
private bool ShowsCentroids => state.DisplayMode is OverlapDisplayMode.Centroids or OverlapDisplayMode.Both;
private (float, PointF, int) ViewTransform => (view.ViewScale, view.PointControlToGraph(Point.Empty), view.DeviceDpi);
internal string HoverText
{
get
{
if (!EnsureFresh())
ClearHover();
ValidateHoverTransform();
return hoverText;
}
}
public void UpdateHover(Point point)
{
ValidateHoverTransform();
if (!EnsureFresh() || !ShowsCentroids || state.Report == null)
{
ClearHover();
return;
}
// Graph coordinates are already screen-scaled; remove pan from the pointer once.
var graphPoint = view.PointControlToGraph(point);
var hits = OverlapPairPresentation.HitTest(state.Report.Pairs, world =>
{
var graph = view.PointWorldToGraph(world);
return new Vector(graph.X, graph.Y);
}, new Vector(graphPoint.X, graphPoint.Y), view.DeviceDpi);
var changed = !hits.SequenceEqual(hoveredPairs);
var moved = point != hoverPoint;
hoveredPairs = hits;
hoverPoint = point;
hoverTransform = ViewTransform;
if (changed)
{
hoverPages = null;
HoverBounds = RectangleF.Empty;
hoverText = hits.Count == 0 ? null : string.Join("\n\n",
hits.Select(pair => OverlapPairPresentation.Details(pair, capturedUnits)));
}
if (changed || (moved && hoverText != null))
view.Invalidate();
}
private void ValidateHoverTransform()
{
if (hoverText != null && hoverTransform != ViewTransform)
ClearHover();
}
public void ClearHover()
{
var visible = hoverText != null;
hoverText = null;
hoverPages = null;
HoverBounds = RectangleF.Empty;
hoveredPairs = Array.Empty<PlateOverlapPair>();
if (visible && !disposed && !view.IsDisposed && !view.Disposing)
view.Invalidate();
}
public bool TryPageHover(int delta)
{
// Revalidate even when a key arrives before the next paint/mouse event.
if (HoverText == null || hoverPages == null || hoverPages.PageCount < 2)
return false;
hoverPages.MovePage(delta);
view.Invalidate();
return true;
}
private void DrawCentroids(Graphics graphics)
{
var dpiScale = view.DeviceDpi / 96f;
var radius = (float)OverlapPairPresentation.MarkerHalfSize(view.DeviceDpi);
using var halo = new Pen(Color.White, 4 * dpiScale);
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
// Stack coincident pair labels instead of replacing them with a fragment count.
var labelRows = new Dictionary<PointF, int>();
foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
{
var center = view.PointWorldToGraph(pair.Centroid);
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
continue;
foreach (var pen in new[] { halo, crosshair })
{
graphics.DrawLine(pen, center.X - radius, center.Y, center.X + radius, center.Y);
graphics.DrawLine(pen, center.X, center.Y - radius, center.X, center.Y + radius);
}
var label = OverlapPairPresentation.Label(pair);
var size = graphics.MeasureString(label, view.Font);
labelRows.TryGetValue(center, out var row);
labelRows[center] = row + 1;
var x = center.X + radius + 3 * dpiScale;
var y = center.Y - radius + row * size.Height;
graphics.FillRectangle(Brushes.White, x, y, size.Width, size.Height);
graphics.DrawString(label, view.Font, Brushes.DarkRed, x, y);
}
}
// Draw last, over action adorners. The normal part tooltip is suppressed only while
// these details are actually visible; its independent timer/state is not stolen.
public bool DrawHover(Graphics graphics)
{
var text = HoverText; // Includes a fresh stamp check even if no mouse move/paint preceded this.
if (text == null)
return false;
var saved = graphics.Save();
try
{
graphics.ResetTransform();
graphics.SetClip(view.ClientRectangle, CombineMode.Intersect);
var scale = view.DeviceDpi / 96f;
var padding = 4 * scale;
var maxWidth = System.Math.Min(560 * scale, view.ClientSize.Width - 2) - padding * 2;
var lineHeight = (float)System.Math.Ceiling(view.Font.GetHeight(graphics));
var maxRows = (int)((view.ClientSize.Height - 2 - padding * 2) / lineHeight);
using var format = (StringFormat)StringFormat.GenericTypographic.Clone();
format.FormatFlags |= StringFormatFlags.NoWrap | StringFormatFlags.MeasureTrailingSpaces;
float Measure(string line) => graphics.MeasureString(line, view.Font, PointF.Empty, format).Width;
hoverPages ??= OverlapHoverPages.Create(text, hoveredPairs.Count, maxRows, maxWidth, line => Measure(line));
if (hoverPages.NeedsLargerViewport)
{
// A viewport smaller than a content line plus its paging hint cannot
// show details legibly. Say so rather than presenting clipped data.
HoverBounds = new RectangleF(0, 0, view.ClientSize.Width, view.ClientSize.Height);
graphics.FillRectangle(Brushes.White, HoverBounds);
graphics.DrawString("Enlarge view to read overlap details", view.Font, Brushes.DarkRed, HoverBounds);
return true;
}
var lines = hoverPages.Lines.Concat(hoverPages.NavigationLines).ToArray();
var width = lines.Max(Measure) + padding * 2;
var height = lines.Length * lineHeight + padding * 2;
var x = System.Math.Max(0, System.Math.Min(hoverPoint.X + 16 * scale, view.ClientSize.Width - width));
var y = hoverPoint.Y - height - 6 * scale;
if (y < 0)
y = System.Math.Max(0, System.Math.Min(hoverPoint.Y + 20 * scale, view.ClientSize.Height - height));
HoverBounds = new RectangleF(x, y, width, height);
graphics.FillRectangle(Brushes.White, x, y, width, height);
graphics.DrawRectangle(Pens.DimGray, x, y, width, height);
for (var row = 0; row < lines.Length; row++)
graphics.DrawString(lines[row], view.Font,
row < hoverPages.Lines.Count ? Brushes.Black : Brushes.DarkRed,
new PointF(x + padding, y + padding + row * lineHeight), format);
}
finally
{
graphics.Restore(saved);
}
return true;
}
private void EnsurePath()
{
if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null)
return;
ReleasePath();
var next = new GraphicsPath(FillMode.Winding);
try
{
foreach (var pair in state.DisplayPairs)
{
foreach (var region in pair.Regions)
{
var vertices = region.Vertices;
var points = new PointF[vertices.Count - 1]; // Analyzer repeats the closing vertex.
var signedArea = 0.0;
var start = vertices[0];
for (var i = 0; i < points.Length; i++)
{
var a = vertices[i] - start;
var b = vertices[i + 1] - start;
signedArea += a.X * b.Y - b.X * a.Y;
// The host has already translated by origin: graph, not control coordinates.
points[i] = view.PointWorldToGraph(vertices[i]);
}
if (signedArea < 0)
Array.Reverse(points);
next.AddPolygon(points);
}
}
path = next;
pathPairs = state.DisplayPairs;
pathScale = view.ViewScale;
}
catch
{
next.Dispose();
throw;
}
}
private void DrawStateLabel(Graphics graphics)
{
var saved = graphics.Save();
try
{
graphics.ResetTransform();
var text = Message + (state.DisplayMode == OverlapDisplayMode.Off ? " (display off)" : "");
var size = graphics.MeasureString(text, view.Font);
using var background = new SolidBrush(Color.FromArgb(235, Color.White));
graphics.FillRectangle(background, 6, 6, size.Width + 8, size.Height + 6);
graphics.DrawString(text, view.Font, Brushes.DarkRed, 10, 9);
}
finally
{
graphics.Restore(saved);
}
}
private void ReleasePath()
{
path?.Dispose();
path = null;
pathPairs = null;
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
autoCheckTimer.Stop();
autoCheckTimer.Dispose();
Unsubscribe();
CancelWorker();
state.Reset();
ReleasePath();
ClearHover();
}
}