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 89ecba8..a7c600e 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -1248,16 +1248,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 ed3cf42..28bb019 100644
--- a/OpenNest/GraphicsHelper.cs
+++ b/OpenNest/GraphicsHelper.cs
@@ -39,6 +39,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);
@@ -57,26 +84,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)
@@ -85,11 +120,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;
}
@@ -282,7 +317,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.
@@ -309,7 +345,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();
@@ -329,7 +367,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();
@@ -372,7 +412,7 @@ namespace OpenNest
frameOrigin.X + subpgm.Offset.X,
frameOrigin.Y + subpgm.Offset.Y
);
- AddProgram(path, subpgm.Program, mode, ref curpos);
+ AddProgram(path, subpgm.Program, mode, ref curpos, includeLayer);
}
mode = tmpmode;
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.