feat(ui): add Workshop palette with distinct drawing colors

Keep cut outlines dark and etch strokes bright green. Assign drawing fills by a golden-angle hue walk with varied saturation/lightness tiers, preserving legacy schemes and rendering-only behavior. Include palette, visibility, persistence, and contrast regressions.
This commit is contained in:
aj committed 2026-10-08 10:24:11 -04:00
1 parent 47140cc59e
commit 1826e6d5b5
18 files changed
+916 -112

No files matched your search

+10 -5
View File
@@ -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()) { }
+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Drawing;
namespace OpenNest
{
/// <summary>
/// Drawing colors: golden-angle hue walk (skipping the etch-green band)
/// cycled through eight saturation/lightness tiers for wide separation.
/// </summary>
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));
}
}
}
+49
View File
@@ -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<ArgumentOutOfRangeException>(() => PartColorPalette.GoldenAngle(-1));
}
+262
View File
@@ -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<Part>
{
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();
}
@@ -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> { 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);
}
}
}
@@ -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<TextBox>(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.");
}
@@ -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.");
}
@@ -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.");
}
+14 -1
View File
@@ -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",
+28 -3
View File
@@ -18,6 +18,7 @@ namespace OpenNest
["Classic"] = BuildClassic(),
["Pastel"] = BuildPastel(),
["Dark"] = BuildDark(),
["Workshop"] = BuildWorkshop(),
};
private static List<ColorScheme> 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
{
+12
View File
@@ -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; }
}
}
}
+4 -5
View File
@@ -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();
+3 -5
View File
@@ -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);
}
+3 -2
View File
@@ -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();
+9 -4
View File
@@ -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(
+69 -29
View File
@@ -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<LayerType, bool> 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<LayerType, bool> 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;
}
}
}
+70 -58
View File
@@ -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<PointF[]> _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();
}
}
}
+65
View File
@@ -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.