diff --git a/OpenNest.Desktop.Tests/ColorSchemeRegistryTests.cs b/OpenNest.Desktop.Tests/ColorSchemeRegistryTests.cs new file mode 100644 index 00000000..21a84c75 --- /dev/null +++ b/OpenNest.Desktop.Tests/ColorSchemeRegistryTests.cs @@ -0,0 +1,244 @@ +using System.Drawing; +using OpenNest.Data; + +namespace OpenNest.Desktop.Tests; + +// Applying a scheme changes ColorScheme.Default and the drawing palette for the whole process. +[Collection(ColorSchemeState.Collection)] +public class ColorSchemeRegistryTests : IDisposable +{ + private static readonly string[] BuiltInNames = + ["Classic", "Pastel", "Dark", "Workshop", "Halloween", "Christmas", "4th of July"]; + + private readonly ColorSchemeState saved = new(); + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-schemes-" + Guid.NewGuid().ToString("N")); + + public ColorSchemeRegistryTests() => Directory.CreateDirectory(directory); + + public void Dispose() + { + saved.Restore(); + Directory.Delete(directory, true); + } + + [Fact] + public void BuiltInSchemesAreListedInOrder() + { + ColorSchemeRegistry.SchemesDirectory = directory; + + Assert.Equal(BuiltInNames, ColorSchemeRegistry.AllSchemes.Select(s => s.Name)); + } + + [Theory] + [InlineData("Classic")] + [InlineData("Pastel")] + [InlineData("Dark")] + [InlineData("Workshop")] + [InlineData("Halloween")] + [InlineData("Christmas")] + [InlineData("4th of July")] + public void EveryBuiltInSchemeRoundTripsUnchanged(string name) + { + var scheme = ColorSchemeRegistry.Get(name); + + var json = ColorSchemeSerializer.Serialize(scheme); + var restored = ColorSchemeSerializer.Deserialize(json); + + Assert.Equal(Snapshot(scheme), Snapshot(restored)); + Assert.Equal(json, ColorSchemeSerializer.Serialize(restored)); + } + + [Fact] + public void ASchemeFileInTheSchemesFolderLoads() + { + File.WriteAllText(Path.Combine(directory, "shop.json"), """ + { + "name": "Shop Floor", + "backgroundColor": "#102030", + "layoutOutlineColor": "#405060", + "layoutFillColor": "#F0F0E0", + "boundingBoxColor": "#8080FF", + "rapidColor": "#1E90FF", + "originColor": "#808080", + "edgeSpacingColor": "#B4B4B4", + "previewPartColor": "#FF8C00", + "partOutlineColor": "#202020", + "partColors": ["#AA0000", "#00AA00"] + } + """); + ColorSchemeRegistry.SchemesDirectory = directory; + + var scheme = ColorSchemeRegistry.Get("shop floor"); + + Assert.Equal("Shop Floor", scheme.Name); + Assert.Equal(Color.FromArgb(16, 32, 48), scheme.BackgroundColor); + Assert.Equal(Color.FromArgb(32, 32, 32), scheme.PartOutlineColor); + Assert.Equal([Color.FromArgb(170, 0, 0), Color.FromArgb(0, 170, 0)], scheme.PartColors); + // Missing entries keep their defaults, as WinForms reads them. + Assert.Equal(Color.Lime, scheme.EtchColor); + Assert.True(scheme.SelectedPartColor.IsEmpty); + Assert.Equal(new ColorScheme().LibraryFillColor, scheme.LibraryFillColor); + Assert.Equal(BuiltInNames.Append("Shop Floor"), ColorSchemeRegistry.AllSchemes.Select(s => s.Name)); + } + + [Fact] + public void BadSchemeFilesAreSkippedAndTheirNamesFallBackToClassic() + { + File.WriteAllText(Path.Combine(directory, "broken.json"), "{ \"name\": \"Broken\", "); + File.WriteAllText(Path.Combine(directory, "badhex.json"), "{ \"name\": \"Bad Hex\", \"backgroundColor\": \"#GG0000\" }"); + File.WriteAllText(Path.Combine(directory, "null.json"), "null"); + File.WriteAllText(Path.Combine(directory, "classic.json"), "{ \"name\": \"classic\", \"backgroundColor\": \"#FF0000\" }"); + File.WriteAllText(Path.Combine(directory, "notes.txt"), "{ \"name\": \"Text\" }"); + ColorSchemeRegistry.SchemesDirectory = directory; + + Assert.Equal(BuiltInNames, ColorSchemeRegistry.AllSchemes.Select(s => s.Name)); + Assert.Equal("Classic", ColorSchemeRegistry.Get("Broken").Name); + Assert.Equal("Classic", ColorSchemeRegistry.Get("Bad Hex").Name); + // A file cannot replace a built-in scheme. + Assert.Equal(Color.DarkGray, ColorSchemeRegistry.Get("Classic").BackgroundColor); + Assert.Equal("Classic", ColorSchemeRegistry.Get(null).Name); + Assert.Equal("Classic", ColorSchemeRegistry.Get(" ").Name); + } + + [Fact] + public void AMissingSchemesFolderListsOnlyTheBuiltIns() + { + ColorSchemeRegistry.SchemesDirectory = Path.Combine(directory, "missing"); + + Assert.Equal(BuiltInNames, ColorSchemeRegistry.AllSchemes.Select(s => s.Name)); + } + + [Fact] + public void TheSavedSchemeNameResolvesFromTheSharedUserSettings() + { + var path = Path.Combine(directory, "user-settings.json"); + UserSettingsFile.Write(path, new Dictionary + { + ["MainWindowState"] = "Normal", + [ColorSchemeRegistry.SettingName] = "Halloween", + }); + + var applied = ColorSchemeRegistry.ApplySaved(UserSettingsFile.Read(path)); + + Assert.Equal("ActiveColorScheme", ColorSchemeRegistry.SettingName); + Assert.Equal("Halloween", applied.Name); + Assert.Equal("Halloween", ColorScheme.Default.Name); + Assert.Equal(applied.BackgroundColor, ColorScheme.Default.BackgroundColor); + Assert.Equal(applied.PartColors, Drawing.PartColors); + } + + [Theory] + [InlineData(null, "Classic")] + [InlineData("", "Classic")] + [InlineData("dark", "Dark")] + [InlineData("Gone Scheme", "Classic")] + public void TheSavedSchemeFallsBackToClassic(string? savedName, string expected) + { + var settings = new Dictionary(); + if (savedName != null) + settings[ColorSchemeRegistry.SettingName] = savedName; + + Assert.Equal(savedName ?? "Classic", ColorSchemeRegistry.SavedSchemeName(settings)); + Assert.Equal(expected, ColorSchemeRegistry.ApplySaved(settings).Name); + Assert.Equal(expected, ColorScheme.Default.Name); + } + + [Fact] + public void ApplyingCopiesTheSchemeIntoTheDefaultAndRaisesApplied() + { + var workshop = ColorSchemeRegistry.Get("Workshop"); + var defaultVersion = ColorScheme.Default.Version; + ColorScheme? raised = null; + EventHandler handler = (_, scheme) => raised = scheme; + ColorSchemeRegistry.Applied += handler; + try + { + ColorSchemeRegistry.Apply(workshop); + } + finally + { + ColorSchemeRegistry.Applied -= handler; + } + + Assert.Same(workshop, raised); + Assert.Equal(Snapshot(workshop), Snapshot(ColorScheme.Default)); + Assert.NotEqual(defaultVersion, ColorScheme.Default.Version); + Assert.True(Drawing.UseGoldenAngleColors); + Assert.Equal(PartColorPalette.GoldenAngle(30), Drawing.GetPartColor(30)); + } + + [Fact] + public void RecolorKeepsCustomColorsInTheirSlotsAndSkipsCutoffs() + { + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Classic")); + var nest = new Nest("Recolor"); + var first = new Drawing("First") { Color = Color.Coral }; + var custom = new Drawing("Custom") { Color = Color.Red, HasCustomColor = true }; + var cutoff = new Drawing("Cutoff") { IsCutOff = true, Color = Color.Gray }; + var third = new Drawing("Third") { Color = Color.Coral }; + nest.Drawings.Add(first); + nest.Drawings.Add(custom); + nest.Drawings.Add(cutoff); + nest.Drawings.Add(third); + + ColorSchemeRegistry.RecolorDrawings(nest); + + var classic = ColorSchemeRegistry.Get("Classic").PartColors; + Assert.Equal(classic[0], first.Color); + Assert.Equal(Color.Red, custom.Color); + Assert.Equal(Color.Gray, cutoff.Color); + Assert.Equal(classic[2], third.Color); + } + + [Fact] + public void RecolorFollowsTheActiveSchemesPalette() + { + var nest = new Nest("Recolor"); + var drawings = Enumerable.Range(0, 14).Select(i => new Drawing($"D{i}") { Color = Color.Coral }).ToList(); + foreach (var drawing in drawings) + nest.Drawings.Add(drawing); + + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Dark")); + ColorSchemeRegistry.RecolorDrawings(nest); + var dark = ColorSchemeRegistry.Get("Dark").PartColors; + Assert.Equal(Enumerable.Range(0, 14).Select(i => dark[i % dark.Length]), drawings.Select(d => d.Color)); + + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop")); + ColorSchemeRegistry.RecolorDrawings(nest); + Assert.Equal(Enumerable.Range(0, 14).Select(PartColorPalette.GoldenAngle), drawings.Select(d => d.Color)); + } + + private static string Snapshot(ColorScheme s) => string.Join(";", + s.Name, s.BackgroundColor.ToArgb(), s.LayoutOutlineColor.ToArgb(), s.LayoutFillColor.ToArgb(), + s.BoundingBoxColor.ToArgb(), s.RapidColor.ToArgb(), s.OriginColor.ToArgb(), s.EdgeSpacingColor.ToArgb(), + s.PreviewPartColor.ToArgb(), s.PartOutlineColor.IsEmpty ? "empty" : s.PartOutlineColor.ToArgb().ToString(), + s.SelectedPartColor.IsEmpty ? "empty" : s.SelectedPartColor.ToArgb().ToString(), s.EtchColor.ToArgb(), + s.LibraryBackgroundColor.ToArgb(), s.LibraryFillColor.ToArgb(), s.LibraryOutlineColor.ToArgb(), + s.LibraryEtchColor.ToArgb(), s.LibraryBendColor.ToArgb(), s.UseGoldenAngleColors, + string.Join(",", s.PartColors.Select(c => c.ToArgb()))); +} + +[CollectionDefinition(ColorSchemeState.Collection, DisableParallelization = true)] +public sealed class ColorSchemeStateCollection +{ +} + +/// The process-wide scheme state a test changes, restored afterwards. +public sealed class ColorSchemeState +{ + public const string Collection = "Colour scheme state"; + + private readonly ColorScheme previous = + ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default)); + private readonly Color[] previousPartColors = Drawing.PartColors; + private readonly bool previousGoldenAngle = Drawing.UseGoldenAngleColors; + private readonly string previousDirectory = ColorSchemeRegistry.SchemesDirectory; + + public void Restore() + { + ColorSchemeRegistry.Apply(previous); + Drawing.PartColors = previousPartColors; + Drawing.UseGoldenAngleColors = previousGoldenAngle; + ColorSchemeRegistry.SchemesDirectory = previousDirectory; + } +} diff --git a/OpenNest.Desktop.Tests/ColorShadeTests.cs b/OpenNest.Desktop.Tests/ColorShadeTests.cs new file mode 100644 index 00000000..6a690a43 --- /dev/null +++ b/OpenNest.Desktop.Tests/ColorShadeTests.cs @@ -0,0 +1,39 @@ +using System.Drawing; + +namespace OpenNest.Desktop.Tests; + +public class ColorShadeTests +{ + // Expected values from an independent transcription of WinForms' ControlPaint.HLSColor; + // OpenNest.WinForms.Tests compares against ControlPaint.Dark itself on Windows. + [Theory] + [InlineData(255, 127, 80, 113, 31, 0)] + [InlineData(0, 0, 0, 0, 0, 0)] + [InlineData(255, 255, 255, 85, 85, 85)] + [InlineData(205, 92, 92, 77, 23, 23)] + [InlineData(128, 128, 128, 42, 42, 42)] + [InlineData(90, 150, 200, 23, 50, 72)] + [InlineData(255, 0, 0, 85, 0, 0)] + [InlineData(12, 34, 250, 2, 11, 85)] + public void DarkMatchesTheWinFormsShade(int r, int g, int b, int er, int eg, int eb) + { + Assert.Equal(Color.FromArgb(er, eg, eb), ColorShade.Dark(Color.FromArgb(r, g, b))); + } + + [Fact] + public void DarkIgnoresAlphaAndReturnsAnOpaqueColor() + { + // The WinForms selection fill: alpha 90 over (150, 200, 255). + Assert.Equal(Color.FromArgb(0, 65, 136), ColorShade.Dark(Color.FromArgb(90, 150, 200, 255))); + } + + [Fact] + public void SchemesWithoutAPartOutlineDarkenTheFill() + { + var classic = ColorSchemeRegistry.Get("Classic"); + var workshop = ColorSchemeRegistry.Get("Workshop"); + + Assert.Equal(Color.FromArgb(113, 31, 0), classic.GetPartOutlineColor(Color.Coral)); + Assert.Equal(Color.FromArgb(48, 48, 48), workshop.GetPartOutlineColor(Color.Coral)); + } +} diff --git a/OpenNest.WinForms.Tests/FestiveColorSchemeTests.cs b/OpenNest.Desktop.Tests/FestiveColorSchemeTests.cs similarity index 98% rename from OpenNest.WinForms.Tests/FestiveColorSchemeTests.cs rename to OpenNest.Desktop.Tests/FestiveColorSchemeTests.cs index ce54ff88..8ba2ddc5 100644 --- a/OpenNest.WinForms.Tests/FestiveColorSchemeTests.cs +++ b/OpenNest.Desktop.Tests/FestiveColorSchemeTests.cs @@ -1,6 +1,6 @@ using System.Drawing; -namespace OpenNest.WinForms.Tests; +namespace OpenNest.Desktop.Tests; public class FestiveColorSchemeTests { diff --git a/OpenNest.Desktop/ColorScheme.cs b/OpenNest.Desktop/ColorScheme.cs new file mode 100644 index 00000000..71887685 --- /dev/null +++ b/OpenNest.Desktop/ColorScheme.cs @@ -0,0 +1,178 @@ +using System.Drawing; +using System.Threading; + +namespace OpenNest +{ + /// + /// A colour scheme's colours: the plate view, its parts and the library previews. Pure data + /// shared by both desktop front ends; each draws with its own cache of pens or paints, which + /// it rebuilds when changes. + /// + public sealed class ColorScheme + { + private int version; + private Color backgroundColor; + private Color layoutOutlineColor; + private Color layoutFillColor; + private Color boundingBoxColor; + private Color rapidColor; + private Color originColor; + private Color edgeSpacingColor; + private Color previewPartColor; + private Color[] partColors = Drawing.PartColors; + private bool useGoldenAngleColors; + private Color partOutlineColor = Color.Empty; + private Color selectedPartColor = Color.Empty; + private Color etchColor = Color.Lime; + private Color libraryBackgroundColor = Color.White; + private Color libraryFillColor = Color.FromArgb(214, 219, 226); + private Color libraryOutlineColor = Color.FromArgb(64, 70, 80); + private Color libraryEtchColor = Color.FromArgb(0, 150, 60); + private Color libraryBendColor = Color.FromArgb(40, 100, 210); + + public string Name { get; set; } = "Unnamed"; + + /// + /// Changes whenever a colour does, so a front end's pen or paint cache knows to rebuild. + /// + public int Version => Volatile.Read(ref version); + + public Color[] PartColors + { + get => partColors; + set => Set(ref partColors, value); + } + + public bool UseGoldenAngleColors + { + get => useGoldenAngleColors; + set => Set(ref useGoldenAngleColors, value); + } + + // Empty preserves the legacy outline derived from each part's fill. + public Color PartOutlineColor + { + get => partOutlineColor; + set => Set(ref partOutlineColor, value); + } + + // Empty preserves the legacy translucent selection fill. + public Color SelectedPartColor + { + get => selectedPartColor; + set => Set(ref selectedPartColor, value); + } + + public Color EtchColor + { + get => etchColor; + set => Set(ref etchColor, value); + } + + // Library previews (Shape Library, drawing library, drawing set preview) show drawings + // that are not in the nest yet, so they share one style instead of nest part colors. + public Color LibraryBackgroundColor + { + get => libraryBackgroundColor; + set => Set(ref libraryBackgroundColor, value); + } + + public Color LibraryFillColor + { + get => libraryFillColor; + set => Set(ref libraryFillColor, value); + } + + public Color LibraryOutlineColor + { + get => libraryOutlineColor; + set => Set(ref libraryOutlineColor, value); + } + + public Color LibraryEtchColor + { + get => libraryEtchColor; + set => Set(ref libraryEtchColor, value); + } + + public Color LibraryBendColor + { + get => libraryBendColor; + set => Set(ref libraryBendColor, value); + } + + /// + /// The scheme's part outline or, when it has none, the legacy outline: the fill darkened + /// as WinForms' ControlPaint.Dark does. + /// + public Color GetPartOutlineColor(Color fill) => + PartOutlineColor.IsEmpty ? ColorShade.Dark(fill) : PartOutlineColor; + + public static readonly ColorScheme Default = new ColorScheme + { + Name = "Classic", + BackgroundColor = Color.DarkGray, + LayoutOutlineColor = Color.Gray, + LayoutFillColor = Color.WhiteSmoke, + BoundingBoxColor = Color.FromArgb(128, 128, 255), + RapidColor = Color.DodgerBlue, + OriginColor = Color.Gray, + EdgeSpacingColor = Color.FromArgb(180, 180, 180), + PreviewPartColor = Color.FromArgb(255, 140, 0), + }; + + public Color BackgroundColor + { + get => backgroundColor; + set => Set(ref backgroundColor, value); + } + + public Color LayoutOutlineColor + { + get => layoutOutlineColor; + set => Set(ref layoutOutlineColor, value); + } + + public Color LayoutFillColor + { + get => layoutFillColor; + set => Set(ref layoutFillColor, value); + } + + public Color BoundingBoxColor + { + get => boundingBoxColor; + set => Set(ref boundingBoxColor, value); + } + + public Color RapidColor + { + get => rapidColor; + set => Set(ref rapidColor, value); + } + + public Color OriginColor + { + get => originColor; + set => Set(ref originColor, value); + } + + public Color EdgeSpacingColor + { + get => edgeSpacingColor; + set => Set(ref edgeSpacingColor, value); + } + + public Color PreviewPartColor + { + get => previewPartColor; + set => Set(ref previewPartColor, value); + } + + private void Set(ref T field, T value) + { + field = value; + Interlocked.Increment(ref version); + } + } +} diff --git a/OpenNest/ColorSchemeRegistry.cs b/OpenNest.Desktop/ColorSchemeRegistry.cs similarity index 85% rename from OpenNest/ColorSchemeRegistry.cs rename to OpenNest.Desktop/ColorSchemeRegistry.cs index 75844c13..efc3d1a0 100644 --- a/OpenNest/ColorSchemeRegistry.cs +++ b/OpenNest.Desktop/ColorSchemeRegistry.cs @@ -3,14 +3,21 @@ using System.Collections.Generic; using System.Drawing; using System.IO; using System.Linq; -using System.Windows.Forms; -using OpenNest.Forms; -using OpenNest.Properties; namespace OpenNest { + /// + /// The built-in colour schemes plus any scheme JSON files in , + /// and the active scheme, which lives in . + /// public static class ColorSchemeRegistry { + /// The user setting that names the chosen scheme, as the WinForms app stores it. + public const string SettingName = "ActiveColorScheme"; + + /// The scheme used when none is chosen, or the chosen one no longer exists. + public const string DefaultSchemeName = "Classic"; + private static readonly Dictionary builtIns = new( StringComparer.OrdinalIgnoreCase ) @@ -25,6 +32,27 @@ namespace OpenNest }; private static List diskCache; + private static string schemesDirectory = Path.Combine(AppContext.BaseDirectory, "Schemes"); + + /// + /// Raised after copies a scheme into + /// and the drawing palette, so each front end can recolour its open nests and repaint. + /// + public static event EventHandler Applied; + + /// + /// The folder of scheme JSON files: Schemes beside the application. A file that does not + /// parse is skipped, and one named like a built-in scheme never replaces it. + /// + public static string SchemesDirectory + { + get => schemesDirectory; + set + { + schemesDirectory = value; + Refresh(); + } + } public static IEnumerable AllSchemes { @@ -40,19 +68,27 @@ namespace OpenNest public static ColorScheme Get(string name) { if (string.IsNullOrWhiteSpace(name)) - return builtIns["Classic"]; + return builtIns[DefaultSchemeName]; var hit = AllSchemes.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase) ); - return hit ?? builtIns["Classic"]; + return hit ?? builtIns[DefaultSchemeName]; } - public static void ApplyActiveFromSettings() + /// + /// The scheme name saved in the user settings (), or the default + /// scheme's name when none is saved, as the WinForms setting defaults. + /// + public static string SavedSchemeName(IReadOnlyDictionary settings) => + settings != null && settings.TryGetValue(SettingName, out var name) ? name : DefaultSchemeName; + + /// Applies the scheme saved in the user settings. + public static ColorScheme ApplySaved(IReadOnlyDictionary settings) { - var name = Settings.Default.ActiveColorScheme; - var scheme = Get(name); + var scheme = Get(SavedSchemeName(settings)); Apply(scheme); + return scheme; } public static void Apply(ColorScheme scheme) @@ -81,20 +117,13 @@ namespace OpenNest Drawing.PartColors = scheme.PartColors; Drawing.UseGoldenAngleColors = scheme.UseGoldenAngleColors; - RecolorOpenNests(); - } - - private static void RecolorOpenNests() - { - foreach (Form f in Application.OpenForms) - { - if (f is EditNestForm enf) - RecolorDrawings(enf.Nest); - } + Applied?.Invoke(null, scheme); } /// - /// Gives each non-cutoff drawing its palette color by position. Custom colors are kept, + /// Gives each non-cutoff drawing its palette color by position: on opening a nest, so the + /// view follows the application's scheme rather than the one the file was saved under, + /// and when a scheme is applied. Custom colors are kept, /// but still take their slot so the other drawings' colors don't shift around them. /// public static void RecolorDrawings(Nest nest) @@ -112,8 +141,8 @@ namespace OpenNest private static IEnumerable LoadDiskSchemes() { - var dir = Path.Combine(AppContext.BaseDirectory, "Schemes"); - if (!Directory.Exists(dir)) + var dir = schemesDirectory; + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) yield break; foreach (var path in Directory.GetFiles(dir, "*.json")) diff --git a/OpenNest/ColorSchemeSerializer.cs b/OpenNest.Desktop/ColorSchemeSerializer.cs similarity index 100% rename from OpenNest/ColorSchemeSerializer.cs rename to OpenNest.Desktop/ColorSchemeSerializer.cs diff --git a/OpenNest.Desktop/ColorShade.cs b/OpenNest.Desktop/ColorShade.cs new file mode 100644 index 00000000..7cafd195 --- /dev/null +++ b/OpenNest.Desktop/ColorShade.cs @@ -0,0 +1,122 @@ +using System.Drawing; + +namespace OpenNest +{ + /// + /// The darker shade WinForms' ControlPaint.Dark gives a colour, computed without + /// WinForms so both desktop front ends outline parts alike. A port of the HLS arithmetic in + /// dotnet/winforms (ControlPaint.HLSColor, MIT licence), including its SystemColors.Control + /// special case. + /// + public static class ColorShade + { + private const int ShadowAdjustment = -333; + private const int HLSMax = 240; + private const int RGBMax = 255; + private const int Undefined = HLSMax * 2 / 3; + + /// As ControlPaint.Dark(color): halfway to the darkest shade. + public static Color Dark(Color color) => Dark(color, 0.5f); + + /// As ControlPaint.Dark(color, percOfDarkDark). + public static Color Dark(Color color, float percOfDarkDark) + { + // With the usual colour scheme, ControlDark/DarkDark is not exactly what the HLS + // arithmetic would give, so WinForms uses the system colours for Control. + if (color.ToKnownColor() == KnownColor.Control) + { + if (percOfDarkDark == 0.0f) + return SystemColors.ControlDark; + if (percOfDarkDark == 1.0f) + return SystemColors.ControlDarkDark; + var dark = SystemColors.ControlDark; + var darkDark = SystemColors.ControlDarkDark; + return Color.FromArgb( + (byte)(dark.R - (byte)((dark.R - darkDark.R) * percOfDarkDark)), + (byte)(dark.G - (byte)((dark.G - darkDark.G) * percOfDarkDark)), + (byte)(dark.B - (byte)((dark.B - darkDark.B) * percOfDarkDark))); + } + + var (hue, luminosity, saturation) = ToHls(color); + var zeroLum = luminosity * (ShadowAdjustment + 1000) / 1000; + return FromHls(hue, zeroLum - (int)(zeroLum * percOfDarkDark), saturation); + } + + private static (int Hue, int Luminosity, int Saturation) ToHls(Color color) + { + int r = color.R; + int g = color.G; + int b = color.B; + + var max = System.Math.Max(System.Math.Max(r, g), b); + var min = System.Math.Min(System.Math.Min(r, g), b); + var sum = max + min; + var luminosity = ((sum * HLSMax) + RGBMax) / (2 * RGBMax); + + var dif = max - min; + if (dif == 0) + return (Undefined, luminosity, 0); + + var saturation = luminosity <= (HLSMax / 2) + ? ((dif * HLSMax) + (sum / 2)) / sum + : ((dif * HLSMax) + (2 * RGBMax - sum) / 2) / (2 * RGBMax - sum); + + var rDelta = (((max - r) * (HLSMax / 6)) + (dif / 2)) / dif; + var gDelta = (((max - g) * (HLSMax / 6)) + (dif / 2)) / dif; + var bDelta = (((max - b) * (HLSMax / 6)) + (dif / 2)) / dif; + + int hue; + if (r == max) + hue = bDelta - gDelta; + else if (g == max) + hue = (HLSMax / 3) + rDelta - bDelta; + else + hue = (2 * HLSMax / 3) + gDelta - rDelta; + + if (hue < 0) + hue += HLSMax; + if (hue > HLSMax) + hue -= HLSMax; + + return (hue, luminosity, saturation); + } + + private static Color FromHls(int hue, int luminosity, int saturation) + { + byte r, g, b; + if (saturation == 0) + { + r = g = b = (byte)(luminosity * RGBMax / HLSMax); + } + else + { + var magic2 = luminosity <= (HLSMax / 2) + ? ((luminosity * (HLSMax + saturation)) + (HLSMax / 2)) / HLSMax + : luminosity + saturation - (((luminosity * saturation) + (HLSMax / 2)) / HLSMax); + var magic1 = 2 * luminosity - magic2; + + r = (byte)((HueToRgb(magic1, magic2, hue + HLSMax / 3) * RGBMax + (HLSMax / 2)) / HLSMax); + g = (byte)((HueToRgb(magic1, magic2, hue) * RGBMax + (HLSMax / 2)) / HLSMax); + b = (byte)((HueToRgb(magic1, magic2, hue - HLSMax / 3) * RGBMax + (HLSMax / 2)) / HLSMax); + } + + return Color.FromArgb(r, g, b); + } + + private static int HueToRgb(int n1, int n2, int hue) + { + if (hue < 0) + hue += HLSMax; + if (hue > HLSMax) + hue -= HLSMax; + + if (hue < (HLSMax / 6)) + return n1 + (((n2 - n1) * hue + (HLSMax / 12)) / (HLSMax / 6)); + if (hue < (HLSMax / 2)) + return n2; + if (hue < (HLSMax * 2 / 3)) + return n1 + (((n2 - n1) * ((HLSMax * 2 / 3) - hue) + (HLSMax / 12)) / (HLSMax / 6)); + return n1; + } + } +} diff --git a/OpenNest.Desktop/OpenNest.Desktop.csproj b/OpenNest.Desktop/OpenNest.Desktop.csproj index b0f1978f..e80e9115 100644 --- a/OpenNest.Desktop/OpenNest.Desktop.csproj +++ b/OpenNest.Desktop/OpenNest.Desktop.csproj @@ -15,4 +15,10 @@ + + + + PreserveNewest + + diff --git a/OpenNest/Schemes/.gitkeep b/OpenNest.Desktop/Schemes/.gitkeep similarity index 100% rename from OpenNest/Schemes/.gitkeep rename to OpenNest.Desktop/Schemes/.gitkeep diff --git a/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs b/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs index cc410570..4b64a152 100644 --- a/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs +++ b/OpenNest.WinForms.Tests/Controls/OutlinedPlateDisplayTests.cs @@ -35,7 +35,7 @@ public class OutlinedPlateDisplayTests view.Renderer.DrawPlate(g); - var fill = ((SolidBrush)view.ColorScheme.LayoutFillBrush).Color; + var fill = ((SolidBrush)view.ColorScheme.Gdi().LayoutFillBrush).Color; Assert.Equal(Color.White.ToArgb(), image.GetPixel(75, 25).ToArgb()); // the missing corner Assert.Equal(fill.ToArgb(), image.GetPixel(25, 75).ToArgb()); // the sheet Assert.Equal(fill.ToArgb(), image.GetPixel(75, 75).ToArgb()); // the arm diff --git a/OpenNest.WinForms.Tests/CustomDrawingColorTests.cs b/OpenNest.WinForms.Tests/CustomDrawingColorTests.cs index 56a0e858..afa17f8b 100644 --- a/OpenNest.WinForms.Tests/CustomDrawingColorTests.cs +++ b/OpenNest.WinForms.Tests/CustomDrawingColorTests.cs @@ -6,36 +6,6 @@ namespace OpenNest.WinForms.Tests; [Collection("Fill operation lifetime")] public class CustomDrawingColorTests { - [Fact] - public void RecolorKeepsCustomColorsInTheirSlotsAndSkipsCutoffs() - { - var previous = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default)); - try - { - ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Classic")); - var nest = new Nest("Recolor"); - var first = new Drawing("First") { Color = Color.Coral }; - var custom = new Drawing("Custom") { Color = Color.Red, HasCustomColor = true }; - var cutoff = new Drawing("Cutoff") { IsCutOff = true, Color = Color.Gray }; - var third = new Drawing("Third") { Color = Color.Coral }; - nest.Drawings.Add(first); - nest.Drawings.Add(custom); - nest.Drawings.Add(cutoff); - nest.Drawings.Add(third); - - ColorSchemeRegistry.RecolorDrawings(nest); - - Assert.Equal(Drawing.GetPartColor(0), first.Color); - Assert.Equal(Color.Red, custom.Color); - Assert.Equal(Color.Gray, cutoff.Color); - Assert.Equal(Drawing.GetPartColor(2), third.Color); - } - finally - { - ColorSchemeRegistry.Apply(previous); - } - } - [Fact] public void DrawingPropertiesMarksOnlyAChangedColorAsCustom() => StaTestThread.Run(() => { diff --git a/OpenNest.WinForms.Tests/GdiSchemeToolsTests.cs b/OpenNest.WinForms.Tests/GdiSchemeToolsTests.cs new file mode 100644 index 00000000..f8c25f8d --- /dev/null +++ b/OpenNest.WinForms.Tests/GdiSchemeToolsTests.cs @@ -0,0 +1,57 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace OpenNest.WinForms.Tests; + +public class GdiSchemeToolsTests +{ + [Fact] + public void SharedShadeMatchesControlPaintDark() + { + var colors = new List { SystemColors.Control, Color.Coral, Color.White, Color.Black }; + for (var r = 0; r < 256; r += 15) + { + for (var g = 0; g < 256; g += 17) + { + for (var b = 0; b < 256; b += 13) + colors.Add(Color.FromArgb(r, g, b)); + } + } + + Assert.All(colors, color => Assert.Equal(ControlPaint.Dark(color), ColorShade.Dark(color))); + Assert.All(colors, color => Assert.Equal(ControlPaint.Dark(color, 0.25f), ColorShade.Dark(color, 0.25f))); + } + + [Fact] + public void PensFollowTheSchemeAndAreRebuiltAfterAColorChange() + { + var scheme = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic"))); + var tools = scheme.Gdi(); + + Assert.Same(tools, scheme.Gdi()); + Assert.Equal(scheme.RapidColor.ToArgb(), tools.RapidPen.Color.ToArgb()); + Assert.Equal(Color.FromArgb(30, scheme.PreviewPartColor).ToArgb(), + ((SolidBrush)tools.ActivePreviewPartBrush).Color.ToArgb()); + + scheme.LayoutFillColor = Color.Coral; + var rebuilt = scheme.Gdi(); + + Assert.NotSame(tools, rebuilt); + Assert.Equal(Color.Coral.ToArgb(), ((SolidBrush)rebuilt.LayoutFillBrush).Color.ToArgb()); + } + + [Fact] + public void EachThreadDrawsWithItsOwnPens() + { + var scheme = ColorSchemeRegistry.Get("Workshop"); + var here = scheme.Gdi(); + GdiSchemeTools? other = null; + var thread = new Thread(() => other = scheme.Gdi()); + thread.Start(); + thread.Join(); + + Assert.NotNull(other); + Assert.NotSame(here, other); + Assert.NotSame(here.LayoutOutlinePen, other.LayoutOutlinePen); + } +} diff --git a/OpenNest/ColorScheme.cs b/OpenNest/ColorScheme.cs deleted file mode 100644 index 3496008a..00000000 --- a/OpenNest/ColorScheme.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Threading; - -namespace OpenNest -{ - public sealed class ColorScheme - { - private Color layoutOutlineColor; - private Color layoutFillColor; - private Color boundingBoxColor; - private Color rapidColor; - private Color originColor; - private Color edgeSpacingColor; - private Color previewPartColor; - - public string Name { get; set; } = "Unnamed"; - - public Color[] PartColors { get; set; } = Drawing.PartColors; - - public bool UseGoldenAngleColors { get; set; } - - // Empty preserves the legacy outline derived from each part's fill. - public Color PartOutlineColor { get; set; } = Color.Empty; - - // Empty preserves the legacy translucent selection fill. - public Color SelectedPartColor { get; set; } = Color.Empty; - - public Color EtchColor { get; set; } = Color.Lime; - - // Library previews (Shape Library, drawing library, drawing set preview) show drawings - // that are not in the nest yet, so they share one style instead of nest part colors. - public Color LibraryBackgroundColor { get; set; } = Color.White; - - public Color LibraryFillColor { get; set; } = Color.FromArgb(214, 219, 226); - - public Color LibraryOutlineColor { get; set; } = Color.FromArgb(64, 70, 80); - - public Color LibraryEtchColor { get; set; } = Color.FromArgb(0, 150, 60); - - public Color LibraryBendColor { get; set; } = Color.FromArgb(40, 100, 210); - - public Color GetPartOutlineColor(Color fill) => - PartOutlineColor.IsEmpty ? System.Windows.Forms.ControlPaint.Dark(fill) : PartOutlineColor; - - public static readonly ColorScheme Default = new ColorScheme - { - Name = "Classic", - BackgroundColor = Color.DarkGray, - LayoutOutlineColor = Color.Gray, - LayoutFillColor = Color.WhiteSmoke, - BoundingBoxColor = Color.FromArgb(128, 128, 255), - RapidColor = Color.DodgerBlue, - OriginColor = Color.Gray, - EdgeSpacingColor = Color.FromArgb(180, 180, 180), - PreviewPartColor = Color.FromArgb(255, 140, 0), - }; - - #region Pens/Brushes - - // 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; - - private DrawingTools Tools - { - get - { - var current = tools.Value; - if (current == null || current.Version != toolsVersion) - { - current?.Dispose(); - current = new DrawingTools(this, toolsVersion); - tools.Value = current; - } - - return current; - } - } - - public Pen LayoutOutlinePen => Tools.LayoutOutlinePen; - - public Brush LayoutFillBrush => Tools.LayoutFillBrush; - - public Pen BoundingBoxPen => Tools.BoundingBoxPen; - - public Pen RapidPen => Tools.RapidPen; - - public Pen OriginPen => Tools.OriginPen; - - public Pen EdgeSpacingPen => Tools.EdgeSpacingPen; - - 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 - - #region Colors - - public Color BackgroundColor { get; set; } - - public Color LayoutOutlineColor - { - get { return layoutOutlineColor; } - set { layoutOutlineColor = value; Interlocked.Increment(ref toolsVersion); } - } - - public Color LayoutFillColor - { - get { return layoutFillColor; } - set { layoutFillColor = value; Interlocked.Increment(ref toolsVersion); } - } - - public Color BoundingBoxColor - { - get { return boundingBoxColor; } - set { boundingBoxColor = value; Interlocked.Increment(ref toolsVersion); } - } - - public Color RapidColor - { - get { return rapidColor; } - set { rapidColor = value; Interlocked.Increment(ref toolsVersion); } - } - - public Color OriginColor - { - get { return originColor; } - set { originColor = value; Interlocked.Increment(ref toolsVersion); } - } - - public Color EdgeSpacingColor - { - get { return edgeSpacingColor; } - set { edgeSpacingColor = value; Interlocked.Increment(ref toolsVersion); } - } - - public Color PreviewPartColor - { - get { return previewPartColor; } - set { previewPartColor = value; Interlocked.Increment(ref toolsVersion); } - } - - #endregion Colors - } -} diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index ff5cb272..ba19787b 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -92,14 +92,14 @@ namespace OpenNest.Controls plateRect.Y -= plateRect.Height; edgeSpacingRect.Y -= edgeSpacingRect.Height; - g.FillRectangle(view.ColorScheme.LayoutFillBrush, plateRect); + g.FillRectangle(view.ColorScheme.Gdi().LayoutFillBrush, plateRect); var viewBounds = view.GetViewBounds(); if (!edgeSpacingRect.Contains(viewBounds)) { g.DrawRectangle( - view.ColorScheme.EdgeSpacingPen, + view.ColorScheme.Gdi().EdgeSpacingPen, edgeSpacingRect.X, edgeSpacingRect.Y, edgeSpacingRect.Width, @@ -108,7 +108,7 @@ namespace OpenNest.Controls } g.DrawRectangle( - view.ColorScheme.LayoutOutlinePen, + view.ColorScheme.Gdi().LayoutOutlinePen, plateRect.X, plateRect.Y, plateRect.Width, @@ -259,7 +259,7 @@ namespace OpenNest.Controls sheet.AddPolygon(ToGraph(outline.Points, origin)); foreach (var obstacle in outline.Obstacles) sheet.AddPolygon(ToGraph(obstacle, origin)); - g.FillPath(view.ColorScheme.LayoutFillBrush, sheet); + g.FillPath(view.ColorScheme.Gdi().LayoutFillBrush, sheet); if (outline.Obstacles.Count > 0) { @@ -277,10 +277,10 @@ namespace OpenNest.Controls System.Math.Max(plate.EdgeSpacing.Top, plate.EdgeSpacing.Bottom)); if (edge > 0) foreach (var path in outline.Usable(edge, origin)) - g.DrawPolygon(view.ColorScheme.EdgeSpacingPen, + g.DrawPolygon(view.ColorScheme.Gdi().EdgeSpacingPen, path.Select(p => view.PointWorldToGraph(p.x, p.y)).ToArray()); - g.DrawPath(view.ColorScheme.LayoutOutlinePen, sheet); + g.DrawPath(view.ColorScheme.Gdi().LayoutOutlinePen, sheet); } private PointF[] ToGraph(IReadOnlyList points, Vector origin) => @@ -684,7 +684,7 @@ namespace OpenNest.Controls private void DrawRapids(Graphics g) { - var pen = view.ColorScheme.RapidPen; + var pen = view.ColorScheme.Gdi().RapidPen; foreach (var segment in RapidEnumerator.Enumerate(view.DisplayPlate.Parts)) DrawLine(g, segment.From, segment.To, pen); } @@ -782,7 +782,7 @@ namespace OpenNest.Controls g.DrawLine(pen, point1, point2); } - private void DrawBox(Graphics g, Box box) => DrawBox(g, box, view.ColorScheme.BoundingBoxPen); + private void DrawBox(Graphics g, Box box) => DrawBox(g, box, view.ColorScheme.Gdi().BoundingBoxPen); private void DrawBox(Graphics g, Box box, Pen pen) { diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 895a0516..82a44e43 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -805,8 +805,8 @@ namespace OpenNest.Controls if (DrawOrigin) { - e.Graphics.DrawLine(ColorScheme.OriginPen, origin.X, 0, origin.X, Height); - e.Graphics.DrawLine(ColorScheme.OriginPen, 0, origin.Y, Width, origin.Y); + e.Graphics.DrawLine(ColorScheme.Gdi().OriginPen, origin.X, 0, origin.X, Height); + e.Graphics.DrawLine(ColorScheme.Gdi().OriginPen, 0, origin.Y, Width, origin.Y); } e.Graphics.TranslateTransform(origin.X, origin.Y); diff --git a/OpenNest/Controls/PreviewManager.cs b/OpenNest/Controls/PreviewManager.cs index eea12191..baabdb92 100644 --- a/OpenNest/Controls/PreviewManager.cs +++ b/OpenNest/Controls/PreviewManager.cs @@ -15,9 +15,9 @@ namespace OpenNest.Controls public IReadOnlyList PreviewParts => activeParts; - public Brush PreviewBrush => view.ColorScheme.ActivePreviewPartBrush; + public Brush PreviewBrush => view.ColorScheme.Gdi().ActivePreviewPartBrush; - public Pen PreviewPen => view.ColorScheme.ActivePreviewPartPen; + public Pen PreviewPen => view.ColorScheme.Gdi().ActivePreviewPartPen; public void SetActiveParts(List parts) { diff --git a/OpenNest/Controls/SheetStripControl.cs b/OpenNest/Controls/SheetStripControl.cs index 0d174092..994fd823 100644 --- a/OpenNest/Controls/SheetStripControl.cs +++ b/OpenNest/Controls/SheetStripControl.cs @@ -161,7 +161,7 @@ namespace OpenNest.Controls var sheetHeight = (float)(tile.Sheet.Width * scale); var left = x + (width - sheetWidth) / 2; var sheetRect = new RectangleF(left, baseline - sheetHeight, sheetWidth, sheetHeight); - g.FillRectangle(colorScheme.LayoutFillBrush, sheetRect); + g.FillRectangle(colorScheme.Gdi().LayoutFillBrush, sheetRect); var state = g.Save(); // World Y grows up; the sheet's lower-left corner sits at the rectangle's bottom-left. @@ -177,7 +177,7 @@ namespace OpenNest.Controls } g.Restore(state); - g.DrawRectangle(colorScheme.LayoutOutlinePen, sheetRect.X, sheetRect.Y, sheetRect.Width, sheetRect.Height); + g.DrawRectangle(colorScheme.Gdi().LayoutOutlinePen, sheetRect.X, sheetRect.Y, sheetRect.Width, sheetRect.Height); var caption = new Rectangle(x, (int)baseline + 4, width, CaptionHeight); TextRenderer.DrawText(g, tile.Title + "\n" + tile.Detail, Font, caption, ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.Top); diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 564e7519..e28130c8 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -453,6 +453,20 @@ namespace OpenNest.Forms }; } + // Applying a colour scheme recolours the drawings of every open nest window. Subscribed + // once, before any nest window can exist. + static EditNestForm() + { + ColorSchemeRegistry.Applied += (_, _) => + { + foreach (Form f in Application.OpenForms) + { + if (f is EditNestForm enf) + ColorSchemeRegistry.RecolorDrawings(enf.Nest); + } + }; + } + public EditNestForm(Nest nest) : this() { diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 3f50e325..da08dbce 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -162,7 +162,7 @@ namespace OpenNest.Forms Shown += (_, _) => statusLabel1.Text = engineWarning; OptionsForm.ApplyDisabledStrategies(); - ColorSchemeRegistry.ApplyActiveFromSettings(); + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get(Settings.Default.ActiveColorScheme)); foreach (var name in EngineSelection.UiEngineNames) engineComboBox.Items.Add(name); diff --git a/OpenNest/Forms/OptionsForm.cs b/OpenNest/Forms/OptionsForm.cs index 3884ad0e..70b1149f 100644 --- a/OpenNest/Forms/OptionsForm.cs +++ b/OpenNest/Forms/OptionsForm.cs @@ -141,7 +141,7 @@ namespace OpenNest.Forms Settings.Default.Save(); ApplyDisabledStrategies(); - ColorSchemeRegistry.ApplyActiveFromSettings(); + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get(Settings.Default.ActiveColorScheme)); foreach (Form f in Application.OpenForms) f.Invalidate(invalidateChildren: true); diff --git a/OpenNest/GdiSchemeTools.cs b/OpenNest/GdiSchemeTools.cs new file mode 100644 index 00000000..b30566ca --- /dev/null +++ b/OpenNest/GdiSchemeTools.cs @@ -0,0 +1,102 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace OpenNest +{ + /// + /// The GDI+ pens and brushes the WinForms views draw a with. + /// + /// + /// GDI+ objects are not thread-safe, and views on different UI threads share a scheme + /// (), so each thread draws with its own set, rebuilt after + /// a colour change. Two views painting at once otherwise fail with "Object is currently in + /// use elsewhere". + /// + public sealed class GdiSchemeTools + { + private static readonly ConditionalWeakTable> Cache = new(); + + private GdiSchemeTools(ColorScheme scheme) + { + Version = scheme.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)); + } + + /// This thread's pens and brushes for , current with its colours. + public static GdiSchemeTools For(ColorScheme scheme) + { + var perThread = Cache.GetValue(scheme, _ => new ThreadLocal()); + var current = perThread.Value; + if (current == null || current.Version != scheme.Version) + { + current?.Release(); + current = new GdiSchemeTools(scheme); + perThread.Value = current; + } + + return current; + } + + private 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; } + + // Only the cache that made them releases them, once this thread has built newer ones. + private void Release() + { + LayoutOutlinePen.Dispose(); + LayoutFillBrush.Dispose(); + BoundingBoxPen.Dispose(); + RapidPen.Dispose(); + OriginPen.Dispose(); + EdgeSpacingPen.Dispose(); + PreviewPartPen.Dispose(); + PreviewPartBrush.Dispose(); + ActivePreviewPartPen.Dispose(); + ActivePreviewPartBrush.Dispose(); + } + } + + public static class GdiSchemeToolsExtensions + { + /// This thread's GDI+ pens and brushes for the scheme. + public static GdiSchemeTools Gdi(this ColorScheme scheme) => GdiSchemeTools.For(scheme); + } +} diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj index be1e95c5..1e2866ce 100644 --- a/OpenNest/OpenNest.csproj +++ b/OpenNest/OpenNest.csproj @@ -17,11 +17,6 @@ PreserveNewest - - - PreserveNewest - -