feat(ui): draw library previews in one shared style from the color scheme

The Shape Library, drawing library and drawing set preview each drew
parts their own way (hardcoded green fill, steel-blue fallback, black
outlines with gray etch). They now share a library style on ColorScheme
(background, fill, outline, etch and bend colors) that ignores drawing
colors, follows the active scheme (Dark gets a dark variant) and
round-trips through saved schemes, defaulting when absent. Nest part
colors are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-26 18:23:42 -04:00
1 parent dcb0a5c519
commit e18304066d
12 files changed
+172 -34

No files matched your search

@@ -0,0 +1,68 @@
using System.Drawing;
using OpenNest.CNC;
using OpenNest.Controls;
namespace OpenNest.WinForms.Tests.Controls;
public class LibraryPreviewStyleTests
{
private const int ViewSize = 200;
[Fact]
public void LibraryColorsRoundTripAndOlderSchemesKeepTheDefaults()
{
var scheme = ColorSchemeRegistry.Get("Dark");
var restored = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(scheme));
Assert.Equal(scheme.LibraryBackgroundColor.ToArgb(), restored.LibraryBackgroundColor.ToArgb());
Assert.Equal(scheme.LibraryFillColor.ToArgb(), restored.LibraryFillColor.ToArgb());
Assert.Equal(scheme.LibraryOutlineColor.ToArgb(), restored.LibraryOutlineColor.ToArgb());
Assert.Equal(scheme.LibraryEtchColor.ToArgb(), restored.LibraryEtchColor.ToArgb());
Assert.Equal(scheme.LibraryBendColor.ToArgb(), restored.LibraryBendColor.ToArgb());
var legacy = ColorSchemeSerializer.Deserialize("{\"name\":\"Old\",\"backgroundColor\":\"#101010\"}");
var defaults = new ColorScheme();
Assert.Equal(defaults.LibraryBackgroundColor.ToArgb(), legacy.LibraryBackgroundColor.ToArgb());
Assert.Equal(defaults.LibraryFillColor.ToArgb(), legacy.LibraryFillColor.ToArgb());
Assert.Equal(defaults.LibraryBendColor.ToArgb(), legacy.LibraryBendColor.ToArgb());
}
[Fact]
public void ShapePreviewFillsWithTheLibraryStyleNotTheDrawingColor() => ArchUnitsTests.RunSta(() =>
{
using var preview = new ShapePreviewControl { Size = new Size(ViewSize, ViewSize) };
preview.ShowDrawing(RedSquare());
AssertLibraryStyle(preview);
});
[Fact]
public void DrawingSetPreviewFillsWithTheLibraryStyleNotTheDrawingColor() => ArchUnitsTests.RunSta(() =>
{
using var preview = new DrawingSetPreviewControl { Size = new Size(ViewSize, ViewSize) };
preview.ShowDrawings(new[] { (RedSquare(), "") });
AssertLibraryStyle(preview);
});
private static void AssertLibraryStyle(PlateView preview)
{
using var bitmap = new Bitmap(ViewSize, ViewSize);
preview.DrawToBitmap(bitmap, new Rectangle(0, 0, ViewSize, ViewSize));
var scheme = ColorScheme.Default;
Assert.Equal(scheme.LibraryBackgroundColor.ToArgb(), preview.BackColor.ToArgb());
Assert.Equal(scheme.LibraryFillColor.ToArgb(), bitmap.GetPixel(ViewSize / 2, ViewSize / 2 + 10).ToArgb());
}
private static Drawing RedSquare()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(10, 0));
program.Codes.Add(new LinearMove(10, 10));
program.Codes.Add(new LinearMove(0, 10));
program.Codes.Add(new LinearMove(0, 0));
return new Drawing("square", program) { Color = Color.Red };
}
}
+12
View File
@@ -29,6 +29,18 @@ namespace OpenNest
public Color EtchColor { get; set; } = Color.Lime;
// Library previews (Shape Library, drawing library, drawing set preview) show drawings
// that are not in the nest yet, so they share one style instead of nest part colors.
public Color LibraryBackgroundColor { get; set; } = Color.White;
public Color LibraryFillColor { get; set; } = Color.FromArgb(214, 219, 226);
public Color LibraryOutlineColor { get; set; } = Color.FromArgb(64, 70, 80);
public Color LibraryEtchColor { get; set; } = Color.FromArgb(0, 150, 60);
public Color LibraryBendColor { get; set; } = Color.FromArgb(40, 100, 210);
public Color GetPartOutlineColor(Color fill) =>
PartOutlineColor.IsEmpty ? System.Windows.Forms.ControlPaint.Dark(fill) : PartOutlineColor;
+10
View File
@@ -72,6 +72,11 @@ namespace OpenNest
d.PartOutlineColor = scheme.PartOutlineColor;
d.SelectedPartColor = scheme.SelectedPartColor;
d.EtchColor = scheme.EtchColor;
d.LibraryBackgroundColor = scheme.LibraryBackgroundColor;
d.LibraryFillColor = scheme.LibraryFillColor;
d.LibraryOutlineColor = scheme.LibraryOutlineColor;
d.LibraryEtchColor = scheme.LibraryEtchColor;
d.LibraryBendColor = scheme.LibraryBendColor;
Drawing.PartColors = scheme.PartColors;
Drawing.UseGoldenAngleColors = scheme.UseGoldenAngleColors;
@@ -333,6 +338,11 @@ namespace OpenNest
OriginColor = Color.FromArgb(120, 120, 130),
EdgeSpacingColor = Color.FromArgb(90, 90, 100),
PreviewPartColor = Color.FromArgb(255, 170, 60),
LibraryBackgroundColor = Color.FromArgb(30, 30, 34),
LibraryFillColor = Color.FromArgb(70, 76, 86),
LibraryOutlineColor = Color.FromArgb(205, 210, 220),
LibraryEtchColor = Color.FromArgb(120, 230, 120),
LibraryBendColor = Color.FromArgb(100, 180, 255),
PartColors = new[]
{
Color.FromArgb(255, 85, 85), // Neon Red
+22 -1
View File
@@ -29,6 +29,11 @@ namespace OpenNest
PartOutlineColor = scheme.PartOutlineColor.IsEmpty ? null : ToHex(scheme.PartOutlineColor),
SelectedPartColor = scheme.SelectedPartColor.IsEmpty ? null : ToHex(scheme.SelectedPartColor),
EtchColor = ToHex(scheme.EtchColor),
LibraryBackgroundColor = ToHex(scheme.LibraryBackgroundColor),
LibraryFillColor = ToHex(scheme.LibraryFillColor),
LibraryOutlineColor = ToHex(scheme.LibraryOutlineColor),
LibraryEtchColor = ToHex(scheme.LibraryEtchColor),
LibraryBendColor = ToHex(scheme.LibraryBendColor),
PartColors = scheme.PartColors.Select(ToHex).ToArray(),
UseGoldenAngleColors = scheme.UseGoldenAngleColors,
};
@@ -41,7 +46,7 @@ namespace OpenNest
JsonSerializer.Deserialize<ColorSchemeDto>(json, JsonOptions)
?? throw new JsonException("ColorScheme JSON was null");
return new ColorScheme
var scheme = new ColorScheme
{
Name = dto.Name ?? "Unnamed",
BackgroundColor = FromHex(dto.BackgroundColor),
@@ -58,8 +63,19 @@ namespace OpenNest
PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray(),
UseGoldenAngleColors = dto.UseGoldenAngleColors,
};
// Schemes saved before the library style keep its defaults.
scheme.LibraryBackgroundColor = FromHexOr(dto.LibraryBackgroundColor, scheme.LibraryBackgroundColor);
scheme.LibraryFillColor = FromHexOr(dto.LibraryFillColor, scheme.LibraryFillColor);
scheme.LibraryOutlineColor = FromHexOr(dto.LibraryOutlineColor, scheme.LibraryOutlineColor);
scheme.LibraryEtchColor = FromHexOr(dto.LibraryEtchColor, scheme.LibraryEtchColor);
scheme.LibraryBendColor = FromHexOr(dto.LibraryBendColor, scheme.LibraryBendColor);
return scheme;
}
private static Color FromHexOr(string hex, Color fallback) =>
string.IsNullOrWhiteSpace(hex) ? fallback : FromHex(hex);
private static string ToHex(Color c) =>
"#" + c.R.ToString("X2") + c.G.ToString("X2") + c.B.ToString("X2");
@@ -102,6 +118,11 @@ namespace OpenNest
public string PartOutlineColor { get; set; }
public string SelectedPartColor { get; set; }
public string EtchColor { get; set; }
public string LibraryBackgroundColor { get; set; }
public string LibraryFillColor { get; set; }
public string LibraryOutlineColor { get; set; }
public string LibraryEtchColor { get; set; }
public string LibraryBendColor { get; set; }
public string[] PartColors { get; set; }
public bool UseGoldenAngleColors { get; set; }
}
-1
View File
@@ -514,7 +514,6 @@ namespace OpenNest.Controls
previewBox.AllowPan = false;
previewBox.AllowSelect = false;
previewBox.AllowZoom = false;
previewBox.BackColor = System.Drawing.Color.White;
previewBox.Cursor = System.Windows.Forms.Cursors.Default;
previewBox.Dock = System.Windows.Forms.DockStyle.Fill;
previewBox.Name = "previewBox";
+7 -20
View File
@@ -8,7 +8,7 @@ using OpenNest.Geometry;
namespace OpenNest.Controls
{
/// <summary>
/// Shows every drawing of a set side by side at one scale, outlined without fill, packed by
/// Shows every drawing of a set side by side at one scale in the library style, packed by
/// <see cref="DrawingSetPreviewLayout"/> with a label under each and the info lines above.
/// The layout is repacked to fit whenever the control is resized; the wheel zooms, the
/// middle button pans and a middle double-click fits the set again.
@@ -29,7 +29,7 @@ namespace OpenNest.Controls
AllowSelect = false;
AllowZoom = true;
AllowDrop = false;
BackColor = Color.White;
BackColor = ColorScheme.Default.LibraryBackgroundColor;
// A large set in millimeters fits at well under the default floor of 0.3 px per unit.
ViewScaleMin = 1e-5f;
}
@@ -138,28 +138,13 @@ namespace OpenNest.Controls
e.Graphics.SmoothingMode = SmoothingMode.HighQuality;
e.Graphics.TranslateTransform(origin.X, origin.Y);
PaintOutlines(e.Graphics);
Renderer.DrawLibraryParts(e.Graphics, ColorScheme.Default);
PaintLabels(e.Graphics);
e.Graphics.ResetTransform();
PaintInfo(e.Graphics);
}
// Outlines only: cuts in black, etch marks dashed in gray.
private void PaintOutlines(Graphics g)
{
using var etchPen = new Pen(Color.Gray) { DashStyle = DashStyle.Dash };
foreach (var part in LayoutParts)
{
if (part.IsDirty)
part.Update(this);
if (part.EtchPath?.PointCount > 0)
g.DrawPath(etchPen, part.EtchPath);
if (part.Path?.PointCount > 0)
g.DrawPath(Pens.Black, part.Path);
}
}
private void PaintLabels(Graphics g)
{
if (layout == null)
@@ -172,13 +157,14 @@ namespace OpenNest.Controls
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = StringFormatFlags.NoWrap,
};
using var text = new SolidBrush(ColorScheme.Default.LibraryOutlineColor);
foreach (var placement in layout.Placements)
{
var corner = PointWorldToGraph(placement.CellX, placement.LabelTop);
var rect = new RectangleF(corner.X, corner.Y + 1,
LengthWorldToGui(placement.CellWidth), System.Math.Max(LengthWorldToGui(layout.LabelHeight), Font.Height));
g.DrawString(items[placement.Index].Label, Font, Brushes.Black, rect, format);
g.DrawString(items[placement.Index].Label, Font, text, rect, format);
}
}
@@ -191,12 +177,13 @@ namespace OpenNest.Controls
Trimming = StringTrimming.EllipsisCharacter,
FormatFlags = StringFormatFlags.NoWrap,
};
using var text = new SolidBrush(ColorScheme.Default.LibraryOutlineColor);
foreach (var line in infoLines)
{
if (string.IsNullOrEmpty(line))
continue;
g.DrawString(line, Font, Brushes.Black, new RectangleF(4, y, System.Math.Max(Width - 8, 1), lineHeight), format);
g.DrawString(line, Font, text, new RectangleF(4, y, System.Math.Max(Width - 8, 1), lineHeight), format);
y += lineHeight;
}
}
+23
View File
@@ -109,6 +109,22 @@ namespace OpenNest.Controls
);
}
/// <summary>
/// Draws every part in <paramref name="scheme"/>'s library style with its bend lines,
/// without ids, selection or validation states.
/// </summary>
public void DrawLibraryParts(Graphics g, ColorScheme scheme)
{
using var bendPen = new Pen(scheme.LibraryBendColor, 1.5f) { DashStyle = DashStyle.Dash };
foreach (var part in view.DisplayLayoutParts)
{
if (part.IsDirty)
part.UpdateView(view);
part.DrawLibrary(g, scheme);
DrawBendLines(g, part.BasePart, bendPen);
}
}
public void DrawParts(Graphics g)
{
var viewBounds = view.GetViewBounds();
@@ -439,6 +455,13 @@ namespace OpenNest.Controls
{
DashStyle = System.Drawing.Drawing2D.DashStyle.Dash,
};
DrawBendLines(g, part, bendPen);
}
private void DrawBendLines(Graphics g, Part part, Pen bendPen)
{
if (part.BaseDrawing.Bends == null)
return;
foreach (var bend in part.BaseDrawing.Bends)
{
+4 -3
View File
@@ -16,7 +16,7 @@ namespace OpenNest.Controls
AllowSelect = false;
AllowZoom = false;
AllowDrop = false;
BackColor = Color.White;
BackColor = ColorScheme.Default.LibraryBackgroundColor;
}
public void SetInfo(params string[] lines)
@@ -55,7 +55,7 @@ namespace OpenNest.Controls
e.Graphics.TranslateTransform(origin.X, origin.Y);
Renderer.DrawPlate(e.Graphics);
Renderer.DrawParts(e.Graphics);
Renderer.DrawLibraryParts(e.Graphics, ColorScheme.Default);
e.Graphics.ResetTransform();
PaintInfo(e.Graphics);
@@ -68,12 +68,13 @@ namespace OpenNest.Controls
var lineHeight = Font.GetHeight(g) + 1;
var y = 4f;
using var text = new SolidBrush(ColorScheme.Default.LibraryOutlineColor);
foreach (var line in infoLines)
{
if (string.IsNullOrEmpty(line))
continue;
g.DrawString(line, Font, Brushes.Black, 4, y);
g.DrawString(line, Font, text, 4, y);
y += lineHeight;
}
}
-1
View File
@@ -202,7 +202,6 @@ namespace OpenNest.Forms
previewBox.AllowPan = false;
previewBox.AllowSelect = false;
previewBox.AllowZoom = false;
previewBox.BackColor = System.Drawing.Color.White;
colorScheme1.BackgroundColor = System.Drawing.Color.DarkGray;
colorScheme1.BoundingBoxColor = System.Drawing.Color.FromArgb(128, 128, 255);
colorScheme1.EdgeSpacingColor = System.Drawing.Color.FromArgb(180, 180, 180);
+6 -6
View File
@@ -27,9 +27,6 @@ namespace OpenNest.Forms
private const int TileColumns = 3;
private const int LVM_SETICONSPACING = 0x1000 + 53;
// The fill the preview gives a drawing without a color, so tiles match it.
private static readonly Color ThumbnailFill = Color.FromArgb(130, 204, 130);
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
@@ -125,6 +122,8 @@ namespace OpenNest.Forms
ImageSize = new Size(ThumbnailSize, ThumbnailSize),
};
shapeListView.LargeImageList = images;
shapeListView.BackColor = ColorScheme.Default.LibraryBackgroundColor;
shapeListView.ForeColor = ColorScheme.Default.LibraryOutlineColor;
shapeListView.HandleCreated += (_, _) => ApplyTileSpacing();
Disposed += (_, _) => images.Dispose();
@@ -161,9 +160,10 @@ namespace OpenNest.Forms
// GetImage draws without antialiasing; render at twice the size and
// downsample so curved edges stay smooth at thumbnail scale.
var renderSize = new Size(ThumbnailSize * 2, ThumbnailSize * 2);
using var brush = new SolidBrush(ThumbnailFill);
using var pen = new Pen(ColorScheme.Default.GetPartOutlineColor(ThumbnailFill), 2f);
using var large = program.GetImage(renderSize, pen, brush);
var scheme = ColorScheme.Default;
using var brush = new SolidBrush(scheme.LibraryFillColor);
using var pen = new Pen(scheme.LibraryOutlineColor, 2f);
using var large = program.GetImage(renderSize, pen, brush, scheme.LibraryEtchColor);
using var graphics = Graphics.FromImage(thumbnail);
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
+3 -2
View File
@@ -108,7 +108,8 @@ namespace OpenNest
this Program pgm,
System.Drawing.Size size,
Pen pen,
Brush brush
Brush brush,
Color? etchColor = null
)
{
var img = new Bitmap(size.Width, size.Height);
@@ -152,7 +153,7 @@ namespace OpenNest
if (pen == null)
pen = Pens.Black;
using var etchPen = new Pen(ColorScheme.Default.EtchColor, 1.5f);
using var etchPen = new Pen(etchColor ?? ColorScheme.Default.EtchColor, 1.5f);
graphics.DrawPath(etchPen, etchPath);
// A coincident cut must remain visible over an etch.
graphics.DrawPath(pen, path);
+17
View File
@@ -143,6 +143,23 @@ namespace OpenNest
g.DrawPath(muted ? Pens.Gray : LeadInPen, LeadInPath);
}
/// <summary>
/// Draws the part in the scheme's library style, ignoring its drawing color: library
/// previews show drawings that are not in the nest yet.
/// </summary>
internal void DrawLibrary(Graphics g, ColorScheme scheme)
{
using var fill = new SolidBrush(scheme.LibraryFillColor);
using var outline = new Pen(scheme.LibraryOutlineColor);
using var etch = new Pen(scheme.LibraryEtchColor, 1.5f);
g.FillPath(fill, MaterialPath);
DrawDisplay(g, false);
if (EtchPath?.PointCount > 0)
g.DrawPath(etch, EtchPath);
// Keep real cuts visible even where an etch overlaps them.
g.DrawPath(outline, Path);
}
public void Draw(Graphics g, string id) => Draw(g, id, false);
internal void Draw(Graphics g, string id, bool muted)