mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 19:04:13 -04:00
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:
1 parent
47140cc59e
commit
1826e6d5b5
18 files changed
+916
-112
No files matched your search
@@ -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()) { }
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user