diff --git a/OpenNest.WinForms.Tests/Controls/ShapePreviewConcurrencyTests.cs b/OpenNest.WinForms.Tests/Controls/ShapePreviewConcurrencyTests.cs new file mode 100644 index 00000000..14cf976f --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/ShapePreviewConcurrencyTests.cs @@ -0,0 +1,68 @@ +using System.Drawing; +using System.Windows.Forms; +using OpenNest.CNC; +using OpenNest.Controls; + +namespace OpenNest.WinForms.Tests.Controls; + +public class ShapePreviewConcurrencyTests +{ + private static Program Square(double size) + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(size, 0)); + program.Codes.Add(new LinearMove(size, size)); + program.Codes.Add(new LinearMove(0, size)); + program.Codes.Add(new LinearMove(0, 0)); + return program; + } + + // Parallel test collections paint previews on separate STA threads at once. + // GDI+ objects are not thread-safe, so any pen, brush or font shared between + // views fails with "Object is currently in use elsewhere". + [Fact] + public void PreviewsPaintAtTheSameTimeOnSeparateThreads() + { + const int threadCount = 4; + const int paints = 200; + var start = new Barrier(threadCount); + var failures = new List(); + var threads = new List(); + + for (var t = 0; t < threadCount; t++) + { + var thread = new Thread(() => + { + try + { + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); + using var preview = new ShapePreviewControl { Size = new Size(200, 200) }; + preview.ShowDrawing(new Drawing("square", Square(10))); + preview.SetInfo("10 x 10", "1 part"); + using var bitmap = new Bitmap(200, 200); + + start.SignalAndWait(TimeSpan.FromSeconds(10)); + for (var i = 0; i < paints; i++) + preview.DrawToBitmap(bitmap, new Rectangle(0, 0, 200, 200)); + } + catch (Exception ex) + { + lock (failures) + failures.Add(ex); + } + }) + { + IsBackground = true, + }; + thread.SetApartmentState(ApartmentState.STA); + threads.Add(thread); + thread.Start(); + } + + foreach (var thread in threads) + Assert.True(thread.Join(TimeSpan.FromMinutes(1)), "A preview thread did not finish."); + + Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures)); + } +} diff --git a/OpenNest/ColorScheme.cs b/OpenNest/ColorScheme.cs index b80d5bfc..1ecf7df9 100644 --- a/OpenNest/ColorScheme.cs +++ b/OpenNest/ColorScheme.cs @@ -1,5 +1,7 @@ +using System; using System.Drawing; using System.Drawing.Drawing2D; +using System.Threading; namespace OpenNest { @@ -45,25 +47,110 @@ namespace OpenNest #region Pens/Brushes - public Pen LayoutOutlinePen { get; private set; } + // GDI+ objects are not thread-safe, and views on different UI threads share a + // scheme (Default), so each thread draws with its own set, rebuilt after a color + // change. Two views painting at once otherwise fail with "Object is currently in + // use elsewhere". + private readonly ThreadLocal tools = new(); + private int toolsVersion; - public Brush LayoutFillBrush { get; private set; } + private DrawingTools Tools + { + get + { + var current = tools.Value; + if (current == null || current.Version != toolsVersion) + { + current?.Dispose(); + current = new DrawingTools(this, toolsVersion); + tools.Value = current; + } - public Pen BoundingBoxPen { get; private set; } + return current; + } + } - public Pen RapidPen { get; private set; } + public Pen LayoutOutlinePen => Tools.LayoutOutlinePen; - public Pen OriginPen { get; private set; } + public Brush LayoutFillBrush => Tools.LayoutFillBrush; - public Pen EdgeSpacingPen { get; private set; } + public Pen BoundingBoxPen => Tools.BoundingBoxPen; - public Pen PreviewPartPen { get; private set; } + public Pen RapidPen => Tools.RapidPen; - public Brush PreviewPartBrush { get; private set; } + public Pen OriginPen => Tools.OriginPen; - public Pen ActivePreviewPartPen { get; private set; } + public Pen EdgeSpacingPen => Tools.EdgeSpacingPen; - public Brush ActivePreviewPartBrush { get; private set; } + public Pen PreviewPartPen => Tools.PreviewPartPen; + + public Brush PreviewPartBrush => Tools.PreviewPartBrush; + + public Pen ActivePreviewPartPen => Tools.ActivePreviewPartPen; + + public Brush ActivePreviewPartBrush => Tools.ActivePreviewPartBrush; + + private sealed class DrawingTools : IDisposable + { + public DrawingTools(ColorScheme scheme, int version) + { + Version = version; + LayoutOutlinePen = new Pen(scheme.layoutOutlineColor); + LayoutFillBrush = new SolidBrush(scheme.layoutFillColor); + BoundingBoxPen = new Pen(scheme.boundingBoxColor); + RapidPen = new Pen(scheme.rapidColor) + { + DashPattern = new float[] { 10, 10 }, + DashCap = DashCap.Flat, + }; + OriginPen = new Pen(scheme.originColor); + EdgeSpacingPen = new Pen(scheme.edgeSpacingColor) + { + DashPattern = new float[] { 3, 3 }, + DashCap = DashCap.Flat, + }; + PreviewPartPen = new Pen(scheme.previewPartColor, 1); + PreviewPartBrush = new SolidBrush(Color.FromArgb(60, scheme.previewPartColor)); + ActivePreviewPartPen = new Pen(Color.FromArgb(128, scheme.previewPartColor), 1); + ActivePreviewPartBrush = new SolidBrush(Color.FromArgb(30, scheme.previewPartColor)); + } + + public int Version { get; } + + public Pen LayoutOutlinePen { get; } + + public Brush LayoutFillBrush { get; } + + public Pen BoundingBoxPen { get; } + + public Pen RapidPen { get; } + + public Pen OriginPen { get; } + + public Pen EdgeSpacingPen { get; } + + public Pen PreviewPartPen { get; } + + public Brush PreviewPartBrush { get; } + + public Pen ActivePreviewPartPen { get; } + + public Brush ActivePreviewPartBrush { get; } + + public void Dispose() + { + LayoutOutlinePen.Dispose(); + LayoutFillBrush.Dispose(); + BoundingBoxPen.Dispose(); + RapidPen.Dispose(); + OriginPen.Dispose(); + EdgeSpacingPen.Dispose(); + PreviewPartPen.Dispose(); + PreviewPartBrush.Dispose(); + ActivePreviewPartPen.Dispose(); + ActivePreviewPartBrush.Dispose(); + } + } #endregion Pens/Brushes @@ -74,119 +161,43 @@ namespace OpenNest public Color LayoutOutlineColor { get { return layoutOutlineColor; } - set - { - layoutOutlineColor = value; - - if (LayoutOutlinePen != null) - LayoutOutlinePen.Dispose(); - - LayoutOutlinePen = new Pen(value); - } + set { layoutOutlineColor = value; Interlocked.Increment(ref toolsVersion); } } public Color LayoutFillColor { get { return layoutFillColor; } - set - { - layoutFillColor = value; - - if (LayoutFillBrush != null) - LayoutFillBrush.Dispose(); - - LayoutFillBrush = new SolidBrush(value); - } + set { layoutFillColor = value; Interlocked.Increment(ref toolsVersion); } } public Color BoundingBoxColor { get { return boundingBoxColor; } - set - { - boundingBoxColor = value; - - if (BoundingBoxPen != null) - BoundingBoxPen.Dispose(); - - BoundingBoxPen = new Pen(value); - } + set { boundingBoxColor = value; Interlocked.Increment(ref toolsVersion); } } public Color RapidColor { get { return rapidColor; } - set - { - rapidColor = value; - - if (RapidPen != null) - RapidPen.Dispose(); - - RapidPen = new Pen(value) - { - DashPattern = new float[] { 10, 10 }, - DashCap = DashCap.Flat, - }; - } + set { rapidColor = value; Interlocked.Increment(ref toolsVersion); } } public Color OriginColor { get { return originColor; } - set - { - originColor = value; - - if (OriginPen != null) - OriginPen.Dispose(); - - OriginPen = new Pen(value); - } + set { originColor = value; Interlocked.Increment(ref toolsVersion); } } public Color EdgeSpacingColor { get { return edgeSpacingColor; } - set - { - edgeSpacingColor = value; - - if (EdgeSpacingPen != null) - EdgeSpacingPen.Dispose(); - - EdgeSpacingPen = new Pen(value) - { - DashPattern = new float[] { 3, 3 }, - DashCap = DashCap.Flat, - }; - } + set { edgeSpacingColor = value; Interlocked.Increment(ref toolsVersion); } } public Color PreviewPartColor { get { return previewPartColor; } - set - { - previewPartColor = value; - - if (PreviewPartPen != null) - PreviewPartPen.Dispose(); - - if (PreviewPartBrush != null) - PreviewPartBrush.Dispose(); - - if (ActivePreviewPartPen != null) - ActivePreviewPartPen.Dispose(); - - if (ActivePreviewPartBrush != null) - ActivePreviewPartBrush.Dispose(); - - PreviewPartPen = new Pen(value, 1); - PreviewPartBrush = new SolidBrush(Color.FromArgb(60, value)); - ActivePreviewPartPen = new Pen(Color.FromArgb(128, value), 1); - ActivePreviewPartBrush = new SolidBrush(Color.FromArgb(30, value)); - } + set { previewPartColor = value; Interlocked.Increment(ref toolsVersion); } } #endregion Colors diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index af759887..9df6dd7f 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; @@ -11,11 +12,14 @@ namespace OpenNest { public class LayoutPart : IPart { - private static Font programIdFont; - private static Color selectedColor; - private static Pen selectedPen; - private static Brush selectedBrush; - private static Pen leadInPen; + private static Color selectedColor = Color.FromArgb(90, 150, 200, 255); + + // GDI+ objects are not thread-safe, so views on different UI threads each draw + // with their own; sharing one fails with "Object is currently in use elsewhere". + [ThreadStatic] private static Font programIdFont; + [ThreadStatic] private static Pen selectedPen; + [ThreadStatic] private static Brush selectedBrush; + [ThreadStatic] private static Pen leadInPen; private Color color; private Color? colorOverride; @@ -33,13 +37,6 @@ namespace OpenNest public readonly Part BasePart; - static LayoutPart() - { - programIdFont = new Font(SystemFonts.DefaultFont, FontStyle.Bold | FontStyle.Underline); - SelectedColor = Color.FromArgb(90, 150, 200, 255); - leadInPen = new Pen(Color.OrangeRed, 1.5f); - } - private LayoutPart(Part part) { this.BasePart = part; @@ -105,15 +102,15 @@ namespace OpenNest RefreshColors(); using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty ? new SolidBrush(colorScheme.SelectedPartColor) : null; - g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), + g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? SelectedBrush : brush), MaterialPath); DrawDisplay(g, muted); DrawEtch(g, muted); // Keep real cuts visible even where an etch overlaps them. - g.DrawPath(muted ? Pens.Gray : IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path); + g.DrawPath(muted ? Pens.Gray : IsSelected && colorScheme.PartOutlineColor.IsEmpty ? SelectedPen : pen, Path); if (LeadInPath != null) - g.DrawPath(muted ? Pens.Gray : leadInPen, LeadInPath); + g.DrawPath(muted ? Pens.Gray : LeadInPen, LeadInPath); } public void Draw(Graphics g, string id) => Draw(g, id, false); @@ -129,7 +126,7 @@ namespace OpenNest }; g.DrawString( id, - programIdFont, + ProgramIdFont, muted ? Brushes.DimGray : Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, @@ -393,19 +390,40 @@ namespace OpenNest public static Color SelectedColor { get { return selectedColor; } - set + set { selectedColor = value; } + } + + private static Font ProgramIdFont => + programIdFont ??= new Font(SystemFonts.DefaultFont, FontStyle.Bold | FontStyle.Underline); + + private static Pen LeadInPen => leadInPen ??= new Pen(Color.OrangeRed, 1.5f); + + private static Brush SelectedBrush + { + get { - selectedColor = value; + if (selectedBrush is not SolidBrush current || current.Color != selectedColor) + { + selectedBrush?.Dispose(); + selectedBrush = new SolidBrush(selectedColor); + } - if (selectedBrush != null) - selectedBrush.Dispose(); + return selectedBrush; + } + } - selectedBrush = new SolidBrush(value); + private static Pen SelectedPen + { + get + { + var color = ControlPaint.Dark(selectedColor); + if (selectedPen == null || selectedPen.Color != color) + { + selectedPen?.Dispose(); + selectedPen = new Pen(color); + } - if (selectedPen != null) - selectedPen.Dispose(); - - selectedPen = new Pen(ControlPaint.Dark(value)); + return selectedPen; } }