diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index bb77d72..2cf03ec 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.Linq; @@ -32,13 +32,18 @@ namespace OpenNest Color.FromArgb(215, 130, 130), // Light Coral }; - public static Color GetNextColor() + public static bool UseGoldenAngleColors { get; set; } + + public static Color GetPartColor(int index) { - var color = PartColors[nextColorIndex % PartColors.Length]; - nextColorIndex++; - return color; + ArgumentOutOfRangeException.ThrowIfNegative(index); + return UseGoldenAngleColors + ? PartColorPalette.GoldenAngle(index) + : PartColors[index % PartColors.Length]; } + public static Color GetNextColor() => GetPartColor(unchecked(nextColorIndex++) & int.MaxValue); + public Drawing() : this(string.Empty, new Program()) { } diff --git a/OpenNest.Core/PartColorPalette.cs b/OpenNest.Core/PartColorPalette.cs new file mode 100644 index 0000000..f3b6df7 --- /dev/null +++ b/OpenNest.Core/PartColorPalette.cs @@ -0,0 +1,57 @@ +using System; +using System.Drawing; + +namespace OpenNest +{ + /// + /// Drawing colors: golden-angle hue walk (skipping the etch-green band) + /// cycled through eight saturation/lightness tiers for wide separation. + /// + public static class PartColorPalette + { + // (lightness, saturation) tiers cycled on an irrational stride so any + // window of neighboring parts spans clearly different tones. + private static readonly (double Lightness, double Saturation)[] Tiers = + { + (0.52, 0.68), (0.64, 0.46), (0.75, 0.54), (0.61, 0.87), + (0.79, 0.97), (0.43, 0.82), (0.65, 0.76), (0.48, 0.97), + }; + + // Plastic-number stride: tier(i) never falls into a short repeating phase with the hue walk. + private const double TierStride = 0.7548776662466927; + + // Hues land in [0,95) + [170,295) mapped onto the golden-angle cycle, + // keeping fills out of the bright-green etch band. + private const double HueSpan = 295.0; + private const double BandStart = 95.0; + private const double BandWidth = 75.0; + + public static Color GoldenAngle(int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + var hue = index * 137.508 % HueSpan; + if (hue >= BandStart) + hue += BandWidth; + var (lightness, saturation) = Tiers[ + unchecked((int)(Tiers.Length * (index * TierStride % 1.0)) % Tiers.Length)]; + + var q = lightness < 0.5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation; + var p = 2 * lightness - q; + + int Channel(double t) + { + t = (t % 1 + 1) % 1; + var value = t < 1 / 6.0 ? p + (q - p) * 6 * t + : t < 0.5 ? q + : t < 2 / 3.0 ? p + (q - p) * (2 / 3.0 - t) * 6 + : p; + return (int)System.Math.Round(value * 255); + } + + var h = hue / 360.0; + return Color.FromArgb(Channel(h + 1 / 3.0), Channel(h), Channel(h - 1 / 3.0)); + } + } +} diff --git a/OpenNest.Tests/PartColorPaletteTests.cs b/OpenNest.Tests/PartColorPaletteTests.cs new file mode 100644 index 0000000..5e1edef --- /dev/null +++ b/OpenNest.Tests/PartColorPaletteTests.cs @@ -0,0 +1,49 @@ +using System.Drawing; + +namespace OpenNest.Tests; + +public class PartColorPaletteTests +{ + [Theory] + [InlineData(0, 216, 49, 49)] + [InlineData(1, 98, 160, 234)] + [InlineData(2, 253, 150, 167)] + [InlineData(3, 157, 211, 226)] + public void GoldenAngleMatchesWideRangeHslSamples(int index, int r, int g, int b) + => Assert.Equal(Color.FromArgb(r, g, b), PartColorPalette.GoldenAngle(index)); + + [Fact] + public void GoldenAngleContinuesPastPaletteLengthAndStaysOutOfEtchBand() + { + var colors = Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle).ToArray(); + Assert.NotEqual(colors[0], colors[12]); + Assert.All(colors, color => + { + Assert.Equal(255, color.A); + // The green band belongs to etch strokes; no generated fill may land in it. + var hue = color.GetHue(); + Assert.True(hue < 95 || hue >= 165, $"Fill hue {hue:F0} collides with the etch-green band."); + }); + Assert.Equal(colors, Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle)); + Assert.InRange(PartColorPalette.GoldenAngle(int.MaxValue).GetBrightness(), 0.4f, 0.85f); + } + + [Fact] + public void NeighboringPartsStayFarApartInRgb() + { + static double Distance(Color a, Color b) => + System.Math.Sqrt((a.R - b.R) * (double)(a.R - b.R) + + (a.G - b.G) * (a.G - b.G) + + (a.B - b.B) * (a.B - b.B)); + + var colors = Enumerable.Range(0, 48).Select(PartColorPalette.GoldenAngle).ToArray(); + Assert.Equal(48, colors.Select(c => c.ToArgb()).Distinct().Count()); + for (var i = 0; i < colors.Length - 1; i++) + Assert.True(Distance(colors[i], colors[i + 1]) >= 90, + $"Parts {i} and {i + 1} are too similar: {Distance(colors[i], colors[i + 1]):F0}."); + } + + [Fact] + public void GoldenAngleRejectsNegativeIndices() + => Assert.Throws(() => PartColorPalette.GoldenAngle(-1)); +} diff --git a/OpenNest.WinForms.Tests/EtchDisplayTests.cs b/OpenNest.WinForms.Tests/EtchDisplayTests.cs new file mode 100644 index 0000000..ac464eb --- /dev/null +++ b/OpenNest.WinForms.Tests/EtchDisplayTests.cs @@ -0,0 +1,262 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Geometry; +using Program = OpenNest.CNC.Program; + +namespace OpenNest.WinForms.Tests; + +public class EtchDisplayTests +{ + private static readonly Color EtchColor = Color.Lime; + private static readonly Color CutColor = Color.FromArgb(48, 48, 48); + private static readonly Color PartColor = Color.FromArgb(216, 49, 49); + + [Theory] + [InlineData(false, false, Mode.Absolute)] + [InlineData(false, true, Mode.Absolute)] + [InlineData(true, false, Mode.Absolute)] + [InlineData(true, true, Mode.Absolute)] + [InlineData(false, false, Mode.Incremental)] + [InlineData(false, true, Mode.Incremental)] + [InlineData(true, false, Mode.Incremental)] + [InlineData(true, true, Mode.Incremental)] + public void PlateSeparatesEtchFromCutWithoutChangingProgram(bool leads, bool selected, Mode mode) + => StaTestThread.Run(() => + { + var program = MixedProgram(); + program.Mode = mode; + var before = Snapshot(program); + var drawing = new Drawing("Synthetic etch display", program) { Color = PartColor }; + var part = new Part(drawing, new Vector(10, 10)) { HasManualLeadIns = leads }; + using var view = new PlateView(ColorSchemeRegistry.Get("Workshop")); + view.Matrix = new Matrix(); + var layout = LayoutPart.Create(part, view); + layout.IsSelected = selected; + using var image = new Bitmap(130, 130); + using var g = Graphics.FromImage(image); + g.Clear(Color.White); + layout.Draw(g); + + var renderedFill = image.GetPixel(20, 20); + if (!selected) + Assert.Equal(PartColor.ToArgb(), renderedFill.ToArgb()); + else + Assert.NotEqual(PartColor.ToArgb(), renderedFill.ToArgb()); + Assert.True(Luminance(CutColor) < Luminance(renderedFill)); + Assert.True(Distinctness(EtchColor, renderedFill) >= 15); + // A closed etch is not a hole; an actual closed cut still is. + Assert.Equal(image.GetPixel(20, 20), image.GetPixel(40, 40)); + Assert.Equal(Color.White.ToArgb(), image.GetPixel(90, 90).ToArgb()); + Assert.Equal(EtchColor.ToArgb(), image.GetPixel(30, 40).ToArgb()); + Assert.Equal(EtchColor.ToArgb(), image.GetPixel(40, 70).ToArgb()); + Assert.Equal(EtchColor.ToArgb(), image.GetPixel(75, 40).ToArgb()); + Assert.Equal(CutColor.ToArgb(), image.GetPixel(10, 50).ToArgb()); + Assert.Equal(CutColor.ToArgb(), image.GetPixel(80, 90).ToArgb()); + // Suppression must not leave a visible cut or etch stroke. + Assert.Equal(image.GetPixel(20, 20), image.GetPixel(40, 90)); + Assert.Equal(before, Snapshot(program)); + }, TimeSpan.FromMinutes(1), "Etch display test timed out."); + + [Fact] + public void ActualCutWinsWhenEtchOccupiesTheSameLine() => StaTestThread.Run(() => + { + var program = MixedProgram(); + program.MoveTo(20, 60); + program.Codes.Add(new LinearMove(40, 60) { Layer = LayerType.Cut }); + using var view = new PlateView(ColorSchemeRegistry.Get("Workshop")); + view.Matrix = new Matrix(); + var layout = LayoutPart.Create(new Part(new Drawing("Overlaid cut", program)), view); + using var image = new Bitmap(120, 120); + using var g = Graphics.FromImage(image); + layout.Draw(g); + Assert.Equal(CutColor.ToArgb(), image.GetPixel(30, 60).ToArgb()); + }, TimeSpan.FromMinutes(1), "Overlaid cut test timed out."); + + [Fact] + public void ThumbnailKeepsEtchOutOfFillAndUsesDedicatedColor() + { + var program = MixedProgram(); + using var fill = new SolidBrush(Color.FromArgb(184, 207, 224)); + using var image = (Bitmap)program.GetImage(new System.Drawing.Size(110, 110), Pens.Black, fill); + Assert.Equal(image.GetPixel(15, 95), image.GetPixel(35, 75)); + Assert.Equal(0, image.GetPixel(85, 25).A); + Assert.Equal(ColorScheme.Default.EtchColor.ToArgb(), image.GetPixel(25, 75).ToArgb()); + Assert.Equal(Color.Black.ToArgb(), image.GetPixel(75, 25).ToArgb()); + } + + [Fact] + public void PlateRendererKeepsEtchesInNumberedPartsAndPlacementPreviews() => StaTestThread.Run(() => + { + using var view = new PlateView(ColorSchemeRegistry.Get("Workshop")) + { + Size = new System.Drawing.Size(250, 130), + DrawBounds = false, + }; + view.Matrix = new Matrix(); + view.Plate.Parts.Add(new Part(new Drawing("Placed", MixedProgram()))); + view.Previews.SetActiveParts(new List + { + new(new Drawing("Preview", MixedProgram()), new Vector(120, 0)), + }); + using var image = new Bitmap(250, 130); + using var g = Graphics.FromImage(image); + g.Clear(Color.White); + view.Renderer.DrawParts(g); + Assert.Equal(EtchColor.ToArgb(), image.GetPixel(20, 30).ToArgb()); + Assert.Equal(EtchColor.ToArgb(), image.GetPixel(140, 30).ToArgb()); + Assert.Equal(CutColor.ToArgb(), image.GetPixel(0, 50).ToArgb()); + }, TimeSpan.FromMinutes(1), "Plate preview test timed out."); + + [Fact] + public void WorkshopIsAnAdditionalPaletteNotAReplacementForSavedSchemes() + { + var scheme = ColorSchemeRegistry.Get("Workshop"); + Assert.Equal("Workshop", scheme.Name); + Assert.Equal("Classic", ColorSchemeRegistry.Get("Classic").Name); + Assert.Equal("Pastel", ColorSchemeRegistry.Get("Pastel").Name); + Assert.Equal("Dark", ColorSchemeRegistry.Get("Dark").Name); + Assert.NotEmpty(scheme.PartColors); + Assert.True(scheme.UseGoldenAngleColors); + Assert.Equal(PartColor, scheme.PartColors[0]); + Assert.Equal(Color.FromArgb(242, 242, 242), scheme.LayoutFillColor); + Assert.True(scheme.SelectedPartColor.IsEmpty); + Assert.All(Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle), color => + { + Assert.True(Distinctness(color, EtchColor) >= 15, "Etch must stay distinguishable from generated fills."); + Assert.True(Distinctness(color, CutColor) >= 15, "Cuts must stay distinguishable from generated fills."); + }); + } + + [Fact] + public void SchemeRoundTripAndOldFilesPreserveOutlineFallback() + { + var workshop = ColorSchemeRegistry.Get("Workshop"); + var restored = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(workshop)); + Assert.Equal(CutColor.ToArgb(), restored.PartOutlineColor.ToArgb()); + Assert.Equal(EtchColor.ToArgb(), restored.EtchColor.ToArgb()); + Assert.True(restored.SelectedPartColor.IsEmpty); + Assert.True(restored.UseGoldenAngleColors); + Assert.Equal(workshop.PartColors.Select(c => c.ToArgb()), restored.PartColors.Select(c => c.ToArgb())); + var legacy = System.Text.Json.Nodes.JsonNode.Parse(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic")))!.AsObject(); + legacy.Remove("etchColor"); + legacy.Remove("partOutlineColor"); + legacy.Remove("selectedPartColor"); + legacy.Remove("useGoldenAngleColors"); + var oldScheme = ColorSchemeSerializer.Deserialize(legacy.ToJsonString()); + Assert.True(oldScheme.PartOutlineColor.IsEmpty); + Assert.True(oldScheme.SelectedPartColor.IsEmpty); + Assert.False(oldScheme.UseGoldenAngleColors); + Assert.Equal(System.Windows.Forms.ControlPaint.Dark(Color.Coral), oldScheme.GetPartOutlineColor(Color.Coral)); + Assert.Equal(new ColorScheme().EtchColor.ToArgb(), oldScheme.EtchColor.ToArgb()); + } + + [Fact] + public void PaletteChangeRepaintsExistingPartWithoutMovingIt() => StaTestThread.Run(() => + { + var scheme = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic"))); + var drawing = new Drawing("Recolor", MixedProgram()) { Color = Color.Coral }; + using var view = new PlateView(scheme); + view.Matrix = new Matrix(); + var layout = LayoutPart.Create(new Part(drawing), view); + var path = layout.Path; + scheme.PartOutlineColor = CutColor; + scheme.EtchColor = EtchColor; + drawing.Color = Color.FromArgb(184, 207, 224); + using var image = new Bitmap(120, 120); + using var g = Graphics.FromImage(image); + layout.Draw(g); + Assert.Same(path, layout.Path); + Assert.Equal(drawing.Color.ToArgb(), image.GetPixel(10, 10).ToArgb()); + Assert.Equal(CutColor.ToArgb(), image.GetPixel(0, 50).ToArgb()); + Assert.Equal(EtchColor.ToArgb(), image.GetPixel(20, 30).ToArgb()); + }, TimeSpan.FromMinutes(1), "Scheme repaint test timed out."); + + [Fact] + public void LayerChangesAndSubprogramsDoNotCreateConnectingStrokes() + { + var program = new Program(); + program.MoveTo(10, 10); + program.Codes.Add(new LinearMove(20, 10) { Layer = LayerType.Cut }); + program.Codes.Add(new LinearMove(30, 10) { Layer = LayerType.Scribe }); + program.Codes.Add(new LinearMove(40, 10) { Layer = LayerType.Cut }); + program.Codes.Add(new LinearMove(50, 10) { Layer = LayerType.Leadin }); + program.Codes.Add(new LinearMove(60, 10) { Layer = LayerType.Leadout }); + var child = new Program(Mode.Incremental); + child.MoveTo(2, 3); + child.Codes.Add(new LinearMove(6, 0) { Layer = LayerType.Scribe }); + program.Codes.Add(new SubProgramCall(child, 0) { Offset = new Vector(70, 20) }); + var before = Snapshot(program); + program.GetDisplayPaths(new Vector(5, 5), out var cuts, out var leads, out var etches); + using (cuts) + using (leads) + using (etches) + using (var pen = new Pen(Color.Black, 1)) + { + Assert.True(cuts.IsOutlineVisible(20, 15, pen)); + Assert.True(cuts.IsOutlineVisible(40, 15, pen)); + Assert.False(cuts.IsOutlineVisible(30, 15, pen)); + Assert.True(etches.IsOutlineVisible(30, 15, pen)); + Assert.False(etches.IsOutlineVisible(40, 15, pen)); + Assert.True(leads.IsOutlineVisible(50, 15, pen)); + Assert.True(leads.IsOutlineVisible(60, 15, pen)); + Assert.False(cuts.IsOutlineVisible(50, 15, pen)); + Assert.True(etches.IsOutlineVisible(80, 28, pen)); + Assert.False(etches.IsOutlineVisible(60, 21, pen)); + } + Assert.Equal(before, Snapshot(program)); + } + + private static double Contrast(Color a, Color b) + { + var first = Luminance(a); + var second = Luminance(b); + return (System.Math.Max(first, second) + 0.05) / (System.Math.Min(first, second) + 0.05); + } + + private static double Luminance(Color color) + { + static double Linear(byte channel) + { + var value = channel / 255.0; + return value <= 0.04045 ? value / 12.92 : System.Math.Pow((value + 0.055) / 1.055, 2.4); + } + return 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B); + } + + private static double Distinctness(Color a, Color b) => System.Math.Sqrt( + (a.R - (double)b.R) * (a.R - b.R) + + (a.G - (double)b.G) * (a.G - b.G) + + (a.B - (double)b.B) * (a.B - b.B)); + + private static Program MixedProgram() + { + var program = new Program(); + AddSquare(program, 0, 0, 100, LayerType.Cut); + AddSquare(program, 20, 20, 20, LayerType.Scribe); + AddSquare(program, 70, 70, 20, LayerType.Cut); + program.MoveTo(20, 60); + program.Codes.Add(new LinearMove(40, 60) { Layer = LayerType.Scribe }); + program.MoveTo(65, 30); + program.Codes.Add(new ArcMove(65, 30, 60, 30) { Layer = LayerType.Scribe }); + program.MoveTo(20, 80); + program.Codes.Add(new LinearMove(40, 80) { Layer = LayerType.Scribe, Suppressed = true }); + return program; + } + + private static void AddSquare(Program program, double x, double y, double size, LayerType layer) + { + program.MoveTo(x, y); + foreach (var end in new[] { new Vector(x + size, y), new Vector(x + size, y + size), new Vector(x, y + size), new Vector(x, y) }) + program.Codes.Add(new LinearMove(end) { Layer = layer }); + } + + private static string[] Snapshot(Program program) => program.Codes.Select(code => code switch + { + ArcMove arc => $"arc:{arc.Layer}:{arc.Suppressed}:{arc.EndPoint.X:R}:{arc.EndPoint.Y:R}:{arc.CenterPoint.X:R}:{arc.CenterPoint.Y:R}:{arc.Rotation}", + LinearMove line => $"line:{line.Layer}:{line.Suppressed}:{line.EndPoint.X:R}:{line.EndPoint.Y:R}", + Motion motion => $"{motion.Type}:{motion.Suppressed}:{motion.EndPoint.X:R}:{motion.EndPoint.Y:R}", + _ => code.ToString()! + }).Prepend(program.Mode.ToString()).ToArray(); +} diff --git a/OpenNest.WinForms.Tests/EtchVisibilityTests.cs b/OpenNest.WinForms.Tests/EtchVisibilityTests.cs new file mode 100644 index 0000000..236c170 --- /dev/null +++ b/OpenNest.WinForms.Tests/EtchVisibilityTests.cs @@ -0,0 +1,63 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests; + +public class EtchVisibilityTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void EtchRemainsVisibleWhenNoCutIntersectsViewport(bool includeCut, bool preview) + => StaTestThread.Run(() => + { + var program = new CNC.Program(); + if (includeCut) + AddSquare(program, 0, LayerType.Cut); + AddSquare(program, 100, LayerType.Scribe); + var part = new Part(new Drawing("Panned etch", program)); + using var view = new PannedView(); + if (preview) + view.Previews.SetActiveParts(new List { part }); + else + view.Plate.Parts.Add(part); + var layout = preview ? Assert.Single(view.PreviewParts) : Assert.Single(view.LayoutParts); + Assert.False(layout.Path.GetBounds().IntersectsWith(view.GetViewBounds())); + using var image = new Bitmap(30, 30); + using var g = Graphics.FromImage(image); + g.Clear(Color.White); + g.TranslateTransform(-95, -95); + view.Renderer.DrawParts(g); + Assert.Equal(Color.Lime.ToArgb(), image.GetPixel(5, 15).ToArgb()); + Assert.Equal(Color.White.ToArgb(), image.GetPixel(8, 8).ToArgb()); + if (!includeCut) + Assert.Equal(0, layout.Path.PointCount); + }, TimeSpan.FromMinutes(1), "Panned etch test timed out."); + + private static void AddSquare(CNC.Program program, double origin, LayerType layer) + { + program.MoveTo(origin, origin); + foreach (var end in new[] + { + new Vector(origin + 20, origin), new Vector(origin + 20, origin + 20), + new Vector(origin, origin + 20), new Vector(origin, origin), + }) + program.Codes.Add(new LinearMove(end) { Layer = layer }); + } + + private sealed class PannedView : PlateView + { + public PannedView() : base(ColorSchemeRegistry.Get("Workshop")) + { + Size = new System.Drawing.Size(30, 30); + DrawBounds = false; + Matrix = new Matrix(); + origin = new PointF(-95, -95); + } + } +} diff --git a/OpenNest.WinForms.Tests/Forms/DrawingMetadataColorTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingMetadataColorTests.cs new file mode 100644 index 0000000..7adcf67 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/DrawingMetadataColorTests.cs @@ -0,0 +1,77 @@ +using System.Drawing; +using System.Windows.Forms; +using OpenNest.Forms; +using OpenNest.IO; + +namespace OpenNest.WinForms.Tests.Forms; + +[Collection("Fill operation lifetime")] +public class DrawingMetadataColorTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void MetadataRefreshKeepsPaletteOwnershipWithoutRebuildingPrograms( + bool updateGeometry, bool changeName) => StaTestThread.Run(() => + { + var previousScheme = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default)); + var previousPartColors = Drawing.PartColors; + try + { + var program = new CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(100, 0); + program.LineTo(100, 100); + program.LineTo(0, 100); + program.LineTo(0, 0); + var drawing = new Drawing("Original", program) { Color = Color.Coral }; + var nest = new Nest("Metadata palette"); + nest.Drawings.Add(drawing); + var part = new Part(drawing); + nest.CreatePlate().Parts.Add(part); + using var editor = new EditNestForm(nest); + editor.PlateView.SetOverlapAutoCheck(null); + editor.Show(); + editor.PlateView.Matrix.Reset(); + var layout = Assert.Single(editor.PlateView.LayoutParts); + layout.Update(editor.PlateView); + var path = layout.Path; + var placedProgram = part.Program; + var before = NestWriter.GetProgramText(placedProgram); + + using var properties = new EditDrawingForm(); + properties.LoadDrawing(drawing); + if (changeName) + Assert.IsType(properties.Controls.Find("nameBox", true).Single()).Text = "Renamed"; + properties.SaveDrawing(drawing); + // The live successful-dialog handler calls this color-only refresh. + // Modal interaction itself is covered separately by operator acceptance. + editor.RefreshDrawingColor(drawing); + + Assert.Equal(changeName ? "Renamed" : "Original", drawing.Name); + Assert.Same(path, layout.Path); + Assert.Same(placedProgram, part.Program); + Assert.Equal(before, NestWriter.GetProgramText(part.Program)); + Assert.Equal(Color.Coral, drawing.Color); + + var workshop = ColorSchemeRegistry.Get("Workshop"); + ColorSchemeRegistry.Apply(workshop); + Assert.Equal(workshop.PartColors[0], drawing.Color); + if (updateGeometry) + layout.Update(editor.PlateView); + layout.IsSelected = false; + using var image = new Bitmap(120, 120); + using var g = Graphics.FromImage(image); + layout.Draw(g); + Assert.Equal(drawing.Color.ToArgb(), image.GetPixel(20, 20).ToArgb()); + Assert.Equal(before, NestWriter.GetProgramText(part.Program)); + } + finally + { + ColorSchemeRegistry.Apply(previousScheme); + Drawing.PartColors = previousPartColors; + } + }, TimeSpan.FromMinutes(1), "Metadata color test timed out."); +} diff --git a/OpenNest.WinForms.Tests/GoldenAngleColorTests.cs b/OpenNest.WinForms.Tests/GoldenAngleColorTests.cs new file mode 100644 index 0000000..7b950a7 --- /dev/null +++ b/OpenNest.WinForms.Tests/GoldenAngleColorTests.cs @@ -0,0 +1,80 @@ +using System.Drawing; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests; + +[Collection("Fill operation lifetime")] +public class GoldenAngleColorTests +{ + [Fact] + public void WorkshopRecolorsEachDrawingBeyondAShortPaletteAndKeepsCopiesTogether() + => StaTestThread.Run(() => + { + var previous = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default)); + try + { + var nest = new Nest("Generated drawing colors"); + var cutoff = new Drawing("Cutoff") { IsCutOff = true, Color = Color.Gray }; + for (var i = 0; i < 24; i++) + { + var program = new CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(100, 0); + program.LineTo(100, 100); + program.LineTo(0, 100); + program.LineTo(0, 0); + nest.Drawings.Add(new Drawing($"Drawing {i}", program) { Color = Color.Coral }); + if (i == 0) + nest.Drawings.Add(cutoff); + } + var plate = nest.CreatePlate(); + plate.Parts.Add(new Part(nest.Drawings.First())); + plate.Parts.Add(new Part(nest.Drawings.First())); + using var editor = new EditNestForm(nest); + editor.PlateView.SetOverlapAutoCheck(null); + editor.Show(); + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop")); + + var drawings = nest.Drawings.Where(d => !d.IsCutOff).ToArray(); + Assert.Equal(24, drawings.Select(d => d.Color.ToArgb()).Distinct().Count()); + Assert.Equal(Color.FromArgb(216, 49, 49), drawings[0].Color); + Assert.Equal(Color.FromArgb(98, 160, 234), drawings[1].Color); + Assert.Equal(Color.Gray, cutoff.Color); + Assert.Same(plate.Parts[0].BaseDrawing, plate.Parts[1].BaseDrawing); + var colors = drawings.Select(d => d.Color).ToArray(); + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop")); + Assert.Equal(colors, drawings.Select(d => d.Color)); + } + finally + { + ColorSchemeRegistry.Apply(previous); + } + }, TimeSpan.FromMinutes(1), "Generated palette recolor timed out."); + + [Fact] + public void NewlyImportedColorsUseGeneratedHuesAndSwitchBackToLegacyPalettes() + => StaTestThread.Run(() => + { + var previous = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default)); + try + { + ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop")); + var colors = Enumerable.Range(0, 100).Select(_ => Drawing.GetNextColor()).ToArray(); + Assert.True(colors.Select(c => c.ToArgb()).Distinct().Count() > 90); + Assert.All(colors, color => + { + Assert.InRange(color.GetSaturation(), 0.44f, 0.99f); + Assert.InRange(color.GetBrightness(), 0.4f, 0.82f); + var hue = color.GetHue(); + Assert.True(hue < 95 || hue >= 165); + }); + var classic = ColorSchemeRegistry.Get("Classic"); + ColorSchemeRegistry.Apply(classic); + Assert.Contains(Drawing.GetNextColor(), classic.PartColors); + } + finally + { + ColorSchemeRegistry.Apply(previous); + } + }, TimeSpan.FromMinutes(1), "Generated import colors timed out."); +} diff --git a/OpenNest.WinForms.Tests/LayoutPartColorTests.cs b/OpenNest.WinForms.Tests/LayoutPartColorTests.cs new file mode 100644 index 0000000..e4dd65b --- /dev/null +++ b/OpenNest.WinForms.Tests/LayoutPartColorTests.cs @@ -0,0 +1,41 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using OpenNest.Controls; + +namespace OpenNest.WinForms.Tests; + +public class LayoutPartColorTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BestFitColorOverrideSurvivesPaintAndGeometryUpdate(bool updateGeometry) + => StaTestThread.Run(() => + { + var program = new CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(100, 0); + program.LineTo(100, 100); + program.LineTo(0, 100); + program.LineTo(0, 0); + var drawing = new Drawing("Best fit color", program) { Color = Color.Coral }; + using var cell = new BestFitCell(ColorSchemeRegistry.Get("Classic")) { PartColor = Color.Blue }; + cell.Matrix = new Matrix(); + cell.Plate.Parts.Add(new Part(drawing)); + var layout = Assert.Single(cell.LayoutParts); + Assert.Equal(Color.Blue, layout.Color); + if (updateGeometry) + layout.Update(cell); + using var image = new Bitmap(120, 120); + using var g = Graphics.FromImage(image); + layout.Draw(g); + Assert.Equal(Color.Blue.ToArgb(), image.GetPixel(20, 20).ToArgb()); + Assert.Equal(Color.Blue, layout.Color); + Assert.Equal(Color.Coral, drawing.Color); + + // The existing explicit refresh returns color ownership to the drawing. + layout.Update(); + layout.Draw(g); + Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(20, 20).ToArgb()); + }, TimeSpan.FromMinutes(1), "Best fit color test timed out."); +} diff --git a/OpenNest/ColorScheme.cs b/OpenNest/ColorScheme.cs index 02385d7..b80d5bf 100644 --- a/OpenNest/ColorScheme.cs +++ b/OpenNest/ColorScheme.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; using System.Drawing.Drawing2D; namespace OpenNest @@ -17,6 +17,19 @@ namespace OpenNest 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; + + public Color GetPartOutlineColor(Color fill) => + PartOutlineColor.IsEmpty ? System.Windows.Forms.ControlPaint.Dark(fill) : PartOutlineColor; + public static readonly ColorScheme Default = new ColorScheme { Name = "Classic", diff --git a/OpenNest/ColorSchemeRegistry.cs b/OpenNest/ColorSchemeRegistry.cs index 1e9083e..5405e14 100644 --- a/OpenNest/ColorSchemeRegistry.cs +++ b/OpenNest/ColorSchemeRegistry.cs @@ -18,6 +18,7 @@ namespace OpenNest ["Classic"] = BuildClassic(), ["Pastel"] = BuildPastel(), ["Dark"] = BuildDark(), + ["Workshop"] = BuildWorkshop(), }; private static List diskCache; @@ -64,13 +65,18 @@ namespace OpenNest d.EdgeSpacingColor = scheme.EdgeSpacingColor; d.PreviewPartColor = scheme.PreviewPartColor; d.PartColors = scheme.PartColors; + d.UseGoldenAngleColors = scheme.UseGoldenAngleColors; + d.PartOutlineColor = scheme.PartOutlineColor; + d.SelectedPartColor = scheme.SelectedPartColor; + d.EtchColor = scheme.EtchColor; Drawing.PartColors = scheme.PartColors; + Drawing.UseGoldenAngleColors = scheme.UseGoldenAngleColors; - RecolorOpenNests(scheme.PartColors); + RecolorOpenNests(); } - private static void RecolorOpenNests(Color[] palette) + private static void RecolorOpenNests() { foreach (Form f in Application.OpenForms) { @@ -82,7 +88,7 @@ namespace OpenNest { if (drawing.IsCutOff) continue; - drawing.Color = palette[i % palette.Length]; + drawing.Color = Drawing.GetPartColor(i); i++; } } @@ -110,6 +116,25 @@ namespace OpenNest } } + private static ColorScheme BuildWorkshop() => + new ColorScheme + { + Name = "Workshop", + BackgroundColor = Color.FromArgb(205, 208, 212), + LayoutOutlineColor = Color.FromArgb(145, 150, 156), + LayoutFillColor = Color.FromArgb(242, 242, 242), + BoundingBoxColor = Color.FromArgb(95, 125, 155), + RapidColor = Color.FromArgb(65, 115, 165), + OriginColor = Color.FromArgb(110, 115, 120), + EdgeSpacingColor = Color.FromArgb(185, 190, 196), + PreviewPartColor = Color.FromArgb(100, 145, 180), + PartOutlineColor = Color.FromArgb(48, 48, 48), + EtchColor = Color.Lime, + UseGoldenAngleColors = true, + // Samples for previews/legacy consumers; assignment uses the full index. + PartColors = Enumerable.Range(0, 12).Select(PartColorPalette.GoldenAngle).ToArray(), + }; + private static ColorScheme BuildClassic() => new ColorScheme { diff --git a/OpenNest/ColorSchemeSerializer.cs b/OpenNest/ColorSchemeSerializer.cs index ea8de67..2eb1f42 100644 --- a/OpenNest/ColorSchemeSerializer.cs +++ b/OpenNest/ColorSchemeSerializer.cs @@ -26,7 +26,11 @@ namespace OpenNest OriginColor = ToHex(scheme.OriginColor), EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor), PreviewPartColor = ToHex(scheme.PreviewPartColor), + PartOutlineColor = scheme.PartOutlineColor.IsEmpty ? null : ToHex(scheme.PartOutlineColor), + SelectedPartColor = scheme.SelectedPartColor.IsEmpty ? null : ToHex(scheme.SelectedPartColor), + EtchColor = ToHex(scheme.EtchColor), PartColors = scheme.PartColors.Select(ToHex).ToArray(), + UseGoldenAngleColors = scheme.UseGoldenAngleColors, }; return JsonSerializer.Serialize(dto, JsonOptions); } @@ -48,7 +52,11 @@ namespace OpenNest OriginColor = FromHex(dto.OriginColor), EdgeSpacingColor = FromHex(dto.EdgeSpacingColor), PreviewPartColor = FromHex(dto.PreviewPartColor), + PartOutlineColor = string.IsNullOrWhiteSpace(dto.PartOutlineColor) ? Color.Empty : FromHex(dto.PartOutlineColor), + SelectedPartColor = string.IsNullOrWhiteSpace(dto.SelectedPartColor) ? Color.Empty : FromHex(dto.SelectedPartColor), + EtchColor = string.IsNullOrWhiteSpace(dto.EtchColor) ? Color.Lime : FromHex(dto.EtchColor), PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray(), + UseGoldenAngleColors = dto.UseGoldenAngleColors, }; } @@ -91,7 +99,11 @@ namespace OpenNest public string OriginColor { get; set; } public string EdgeSpacingColor { get; set; } public string PreviewPartColor { get; set; } + public string PartOutlineColor { get; set; } + public string SelectedPartColor { get; set; } + public string EtchColor { get; set; } public string[] PartColors { get; set; } + public bool UseGoldenAngleColors { get; set; } } } } diff --git a/OpenNest/Controls/DrawingListBox.cs b/OpenNest/Controls/DrawingListBox.cs index 92b11dd..a8bce0c 100644 --- a/OpenNest/Controls/DrawingListBox.cs +++ b/OpenNest/Controls/DrawingListBox.cs @@ -1,11 +1,10 @@ -using System; +using System; using System.Drawing; using System.Windows.Forms; +using Size = System.Drawing.Size; namespace OpenNest.Controls { - using Size = System.Drawing.Size; - public class DrawingListBox : ListBox { private const int WM_ERASEBKGND = 0x0014; @@ -98,9 +97,9 @@ namespace OpenNest.Controls var pt = new PointF(5, e.Bounds.Y + 5); var brush = new SolidBrush(dwg.Color); - var pen = new Pen(ControlPaint.Dark(dwg.Color)); + var pen = new Pen(ColorScheme.Default.GetPartOutlineColor(dwg.Color)); - var img = dwg.Program.GetImage(imageSize, pen, brush); + using var img = dwg.Program.GetImage(imageSize, pen, brush); pen.Dispose(); brush.Dispose(); diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index 057769a..8b9180b 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -118,10 +118,7 @@ namespace OpenNest.Controls if (part.IsDirty) part.Update(view); - var path = part.Path; - var pathBounds = path.GetBounds(); - - if (!pathBounds.IntersectsWith(viewBounds)) + if (!part.DisplayBounds.IntersectsWith(viewBounds)) continue; part.Draw(g, (i + 1).ToString()); @@ -142,10 +139,11 @@ namespace OpenNest.Controls part.Update(view); var path = part.Path; - if (!path.GetBounds().IntersectsWith(viewBounds)) + if (!part.DisplayBounds.IntersectsWith(viewBounds)) continue; g.FillPath(previewBrush, path); + part.DrawEtch(g); g.DrawPath(previewPen, path); } diff --git a/OpenNest/Forms/EditDrawingForm.cs b/OpenNest/Forms/EditDrawingForm.cs index cb8ce5a..613efa7 100644 --- a/OpenNest/Forms/EditDrawingForm.cs +++ b/OpenNest/Forms/EditDrawingForm.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; using System.Windows.Forms; namespace OpenNest.Forms @@ -45,7 +45,8 @@ namespace OpenNest.Forms private void UpdateImage() { var brush = new SolidBrush(colorDialog1.Color); - var pen = new Pen(ControlPaint.Dark(colorDialog1.Color)); + var pen = new Pen(ColorScheme.Default.GetPartOutlineColor(colorDialog1.Color)); + DrawingImage?.Dispose(); DrawingImage = drawing.Program.GetImage(pictureBox1.Size, pen, brush); pen.Dispose(); diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 4d4db99..76b03b3 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -1257,16 +1257,21 @@ namespace OpenNest.Forms { form.SaveDrawing(drawing); - // Metadata edits only refresh color; never rebuild the placed part program. - foreach (var layoutPart in PlateView.Parts) - if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing)) - layoutPart.Color = drawing.Color; + RefreshDrawingColor(drawing); UpdateDrawingList(); PlateView.Invalidate(); } } + internal void RefreshDrawingColor(Drawing drawing) + { + // Metadata edits only refresh color; never rebuild the placed part program. + foreach (var layoutPart in PlateView.Parts) + if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing)) + layoutPart.Update(); + } + private void drawingListBox1_DeleteRequested(object sender, Drawing drawing) { var result = MessageBox.Show( diff --git a/OpenNest/GraphicsHelper.cs b/OpenNest/GraphicsHelper.cs index 3487c95..e05c360 100644 --- a/OpenNest/GraphicsHelper.cs +++ b/OpenNest/GraphicsHelper.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; using System.Drawing.Drawing2D; using OpenNest.CNC; using OpenNest.Geometry; @@ -37,6 +37,33 @@ namespace OpenNest return path; } + public static void GetDisplayPaths( + this Program pgm, + Vector origin, + out GraphicsPath cutPath, + out GraphicsPath leadPath, + out GraphicsPath etchPath + ) + { + cutPath = GetLayerPath(pgm, origin, + layer => layer != LayerType.Scribe && layer != LayerType.Leadin && layer != LayerType.Leadout); + leadPath = GetLayerPath(pgm, origin, + layer => layer == LayerType.Leadin || layer == LayerType.Leadout); + etchPath = GetLayerPath(pgm, origin, layer => layer == LayerType.Scribe); + } + + private static GraphicsPath GetLayerPath(Program pgm, Vector origin, System.Func includeLayer) + { + var path = new GraphicsPath(); + var position = Vector.Zero; + AddProgram(path, pgm, pgm.Mode, ref position, includeLayer); + // Place the completed local path; absolute moves must not discard placement. + using var translation = new Matrix(); + translation.Translate((float)origin.X, (float)origin.Y); + path.Transform(translation); + return path; + } + public static Image GetImage(this Program pgm, System.Drawing.Size size) { return pgm.GetImage(size, Pens.Black, null); @@ -55,26 +82,34 @@ namespace OpenNest ) { var img = new Bitmap(size.Width, size.Height); - var path = pgm.GetGraphicsPath(); - var bounds = path.GetBounds(); + pgm.GetDisplayPaths(Vector.Zero, out var cuts, out var leads, out var etches); + using var path = cuts; + using var leadPath = leads; + using var etchPath = etches; + var bounds = path.PointCount > 0 ? path.GetBounds() : etchPath.GetBounds(); + if (path.PointCount > 0 && etchPath.PointCount > 0) + bounds = RectangleF.Union(bounds, etchPath.GetBounds()); - var scalex = (size.Height - 10) / bounds.Height; - var scaley = (size.Width - 10) / bounds.Width; + var scalex = (size.Height - 10) / System.Math.Max(bounds.Height, 0.001f); + var scaley = (size.Width - 10) / System.Math.Max(bounds.Width, 0.001f); var scale = scalex < scaley ? scalex : scaley; - var matrix = new Matrix(); + using var matrix = new Matrix(); matrix.Scale(scale, -scale); path.Transform(matrix); + etchPath.Transform(matrix); - bounds = path.GetBounds(); + bounds = path.PointCount > 0 ? path.GetBounds() : etchPath.GetBounds(); + if (path.PointCount > 0 && etchPath.PointCount > 0) + bounds = RectangleF.Union(bounds, etchPath.GetBounds()); var offset = new PointF( (size.Width - bounds.Width) * 0.5f - bounds.X, (size.Height - bounds.Height) * 0.5f - bounds.Y ); - var graphics = Graphics.FromImage(img); + using var graphics = Graphics.FromImage(img); graphics.TranslateTransform(offset.X, offset.Y); if (brush != null) @@ -83,11 +118,11 @@ namespace OpenNest if (pen == null) pen = Pens.Black; + using var etchPen = new Pen(ColorScheme.Default.EtchColor, 1.5f); + graphics.DrawPath(etchPen, etchPath); + // A coincident cut must remain visible over an etch. graphics.DrawPath(pen, path); - matrix.Dispose(); - graphics.Dispose(); - return img; } @@ -275,7 +310,8 @@ namespace OpenNest curpos = pt; } - private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos) + private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos, + System.Func includeLayer = null) { // Capture the frame origin at entry. Sub-program Offsets are relative // to this fixed origin, not to the current tool position. @@ -302,7 +338,9 @@ namespace OpenNest case CodeType.ArcMove: { var arc = (ArcMove)code; - if (arc.Layer != LayerType.Leadin && arc.Layer != LayerType.Leadout) + if (includeLayer == null + ? arc.Layer != LayerType.Leadin && arc.Layer != LayerType.Leadout + : !arc.Suppressed && includeLayer(arc.Layer)) { if (currentFigure == null) currentFigure = new GraphicsPath(); @@ -322,7 +360,9 @@ namespace OpenNest case CodeType.LinearMove: { var line = (LinearMove)code; - if (line.Layer != LayerType.Leadin && line.Layer != LayerType.Leadout) + if (includeLayer == null + ? line.Layer != LayerType.Leadin && line.Layer != LayerType.Leadout + : !line.Suppressed && includeLayer(line.Layer)) { if (currentFigure == null) currentFigure = new GraphicsPath(); @@ -354,23 +394,23 @@ namespace OpenNest break; case CodeType.SubProgramCall: - { - Flush(); - var tmpmode = mode; - var subpgm = (SubProgramCall)code; - - if (subpgm.Program != null) { - curpos = new Vector( - frameOrigin.X + subpgm.Offset.X, - frameOrigin.Y + subpgm.Offset.Y - ); - AddProgram(path, subpgm.Program, mode, ref curpos); - } + Flush(); + var tmpmode = mode; + var subpgm = (SubProgramCall)code; - mode = tmpmode; - break; - } + if (subpgm.Program != null) + { + curpos = new Vector( + frameOrigin.X + subpgm.Offset.X, + frameOrigin.Y + subpgm.Offset.Y + ); + AddProgram(path, subpgm.Program, mode, ref curpos, includeLayer); + } + + mode = tmpmode; + break; + } } } diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index a5d9c09..aef8c5d 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.Linq; @@ -18,8 +18,10 @@ namespace OpenNest private static Pen leadInPen; private Color color; + private Color? colorOverride; private Brush brush; private Pen pen; + private ColorScheme colorScheme = ColorScheme.Default; private List _offsetPolygonPoints; private double _cachedOffsetSpacing; @@ -45,7 +47,7 @@ namespace OpenNest if (part.BaseDrawing.Color.IsEmpty) part.BaseDrawing.Color = Color.FromArgb(130, 204, 130); - Color = part.BaseDrawing.Color; + SetDisplayColor(part.BaseDrawing.Color); } internal bool IsDirty { get; set; } @@ -56,37 +58,45 @@ namespace OpenNest public GraphicsPath LeadInPath { get; private set; } + public GraphicsPath EtchPath { get; private set; } + + internal RectangleF DisplayBounds { get; private set; } + public Color Color { get { return color; } set { - color = value; - - if (brush != null) - brush.Dispose(); - - brush = new SolidBrush(value); - - if (pen != null) - pen.Dispose(); - - pen = new Pen(ControlPaint.Dark(value)); + colorOverride = value; + SetDisplayColor(value); } } + private void SetDisplayColor(Color value) + { + color = value; + brush?.Dispose(); + brush = new SolidBrush(value); + pen?.Dispose(); + pen = new Pen(colorScheme.GetPartOutlineColor(value)); + } + + private void RefreshColors() + { + var fill = colorOverride ?? BasePart.BaseDrawing.Color; + if (color != fill || pen.Color != colorScheme.GetPartOutlineColor(fill)) + SetDisplayColor(fill); + } + public void Draw(Graphics g) { - if (IsSelected) - { - g.FillPath(selectedBrush, Path); - g.DrawPath(selectedPen, Path); - } - else - { - g.FillPath(brush, Path); - g.DrawPath(pen, Path); - } + RefreshColors(); + using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty + ? new SolidBrush(colorScheme.SelectedPartColor) : null; + g.FillPath(schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), Path); + DrawEtch(g); + // Keep real cuts visible even where an etch overlaps them. + g.DrawPath(IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path); if (LeadInPath != null) g.DrawPath(leadInPen, LeadInPath); @@ -94,19 +104,7 @@ namespace OpenNest public void Draw(Graphics g, string id) { - if (IsSelected) - { - g.FillPath(selectedBrush, Path); - g.DrawPath(selectedPen, Path); - } - else - { - g.FillPath(brush, Path); - g.DrawPath(pen, Path); - } - - if (LeadInPath != null) - g.DrawPath(leadInPen, LeadInPath); + Draw(g); using var sf = new StringFormat { @@ -125,10 +123,18 @@ namespace OpenNest public GraphicsPath OffsetPath { get; private set; } + internal void DrawEtch(Graphics g) + { + if (EtchPath == null || EtchPath.PointCount == 0) + return; + using var etchPen = new Pen(colorScheme.EtchColor, 1.5f); + g.DrawPath(etchPen, EtchPath); + } + private Vector ComputeLabelPoint() { var entities = ConvertProgram.ToGeometry(BasePart.BaseDrawing.Program); - var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + var nonRapid = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList(); var shapes = ShapeBuilder.GetShapes(nonRapid); @@ -158,26 +164,31 @@ namespace OpenNest public void Update(DrawControl plateView) { - if (BasePart.HasManualLeadIns) - { - BasePart.Program.GetGraphicsPaths( - BasePart.Location, - out var cutPath, - out var leadPath - ); - cutPath.Transform(plateView.Matrix); - leadPath.Transform(plateView.Matrix); - Path = cutPath; - LeadInPath?.Dispose(); - LeadInPath = leadPath; - } - else - { - Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location); - Path.Transform(plateView.Matrix); - LeadInPath?.Dispose(); - LeadInPath = null; - } + colorScheme = (plateView as PlateView)?.ColorScheme ?? ColorScheme.Default; + RefreshColors(); + BasePart.Program.GetDisplayPaths(BasePart.Location, + out var cutPath, out var leadPath, out var etchPath); + cutPath.Transform(plateView.Matrix); + leadPath.Transform(plateView.Matrix); + etchPath.Transform(plateView.Matrix); + Path?.Dispose(); + LeadInPath?.Dispose(); + EtchPath?.Dispose(); + Path = cutPath; + EtchPath = etchPath; + LeadInPath = BasePart.HasManualLeadIns ? leadPath : null; + if (!BasePart.HasManualLeadIns) + leadPath.Dispose(); + + var bounds = cutPath.GetBounds(); + if (etchPath.PointCount > 0) + bounds = cutPath.PointCount > 0 ? RectangleF.Union(bounds, etchPath.GetBounds()) : etchPath.GetBounds(); + if (LeadInPath?.PointCount > 0) + bounds = cutPath.PointCount > 0 || etchPath.PointCount > 0 + ? RectangleF.Union(bounds, LeadInPath.GetBounds()) : LeadInPath.GetBounds(); + // Include screen-space stroke width, even for a horizontal or vertical mark. + bounds.Inflate(1, 1); + DisplayBounds = bounds; // _labelPoint is computed from BaseDrawing.Program's current geometry, which already // carries BaseDrawing.Program.Rotation (nonzero for canonical-frame drawings, e.g. in @@ -371,7 +382,8 @@ namespace OpenNest public void Update() { - Color = BasePart.BaseDrawing.Color; + colorOverride = null; + RefreshColors(); } } } diff --git a/docs/nesting-view-colors.md b/docs/nesting-view-colors.md new file mode 100644 index 0000000..449b73e --- /dev/null +++ b/docs/nesting-view-colors.md @@ -0,0 +1,65 @@ +# Nesting-view colors + +Choose **Tools > Options > Color scheme > Workshop**, then **Save** for a +high-contrast per-drawing view: + +- Generated fills: hue advances by 137.508 degrees per drawing (skipping the + green band reserved for etch) and cycles through eight saturation/lightness + tiers on an irrational stride, so parts sharing a sheet stay far apart in + color. All copies of a drawing keep its color. +- Thin dark gray `#303030` cut outlines. +- Bright green `#00FF00` etch strokes, independent of the part fill, so marks + that are not cuts cannot be mistaken for part outlines. +- Pale gray `#F2F2F2` sheet and neutral gray background. + +The generator continues beyond the twelve preview samples instead of wrapping +through a short palette. It maximizes separation between consecutive drawing +indices, not spatial neighbors; very large jobs can still place similar colors +side by side. This is not a colorblind-safe or perceptually uniform palette. Selected parts retain the +standard translucent blue highlight; etch and cut colors stay unchanged. +Classic, Pastel and Dark remain available; saved scheme choices are not changed. +Applying a scheme recolors drawings in currently open nests. Drawings may retain +custom or saved fill colors; open a saved nest before applying Workshop to recolor it. + +In plate views and drawing thumbnails, scribe/etch motions have a separate stroke +from material cuts. A closed etched circle or lettering does not make a hole in +the displayed part fill. Selection changes the part highlight, not the etch color. +Lead-in/out strokes keep their existing orange-red appearance. Preview placements +show etches too. A cut coincident with an etch is drawn over the etch, so it is not +hidden by the mark. + +This is a display aid, not a geometry classifier or machining-safety certificate. +Only motions classified as `Scribe` receive etch styling; a line incorrectly +classified as `Cut` remains a cut line. Drawing geometry, layer classifications, +cutting plans and post-processor output are not changed by the renderer. The CAD +conversion/program-editing views retain their own source/classification colors. + +## Custom scheme files + +Scheme JSON files in `Schemes/` beside the application may specify `etchColor` +and `partOutlineColor` as `#RRGGBB`. Optional `selectedPartColor` specifies an opaque +selection fill; missing or null retains the legacy translucent selection highlight. +Missing `etchColor` uses the bright green etch default. +Missing or null `partOutlineColor` retains the legacy darker-than-fill outline. +Optional `useGoldenAngleColors: true` selects generated drawing colors; missing +or false keeps the explicit `partColors` palette and its legacy cycling behavior. +Workshop tests check the first 1,000 generated fills stay far in RGB distance +from both the dark cut outline and the bright green etch stroke, and that no +fill hue falls in the etch green band. Selected rendering has separate bitmap tests. +Custom or previously saved fills are not automatically contrast-corrected; +apply Workshop after loading the nest to recolor its drawings. Existing scheme +names, saved choices, custom palettes and geometry remain unchanged. + +## Verification + +On Windows, run: + +```powershell +dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj -c Release --filter FullyQualifiedName~EtchDisplayTests +``` + +Then inspect an actual nest with both cuts and etches: select/deselect and move a +part, zoom in/out, inspect drawing thumbnails, and switch away from Workshop and +back. Confirm the marks remain readable, closed marks stay filled and real holes +remain holes. Automated bitmap tests verify the rendering rules, not an operator's +assessment of readability on a particular display.