feat(avalonia): draw plates in the saved colour scheme

The Avalonia app applies the colour scheme saved as ActiveColorScheme in
the shared user-settings file when the main window opens, and recolours a
nest's drawings from it when opening the nest, as WinForms does.

The plate canvas draws the background, sheet, edge spacing, etch, part
outlines and selection from the active scheme through SkiaSchemePalette,
a cache of Skia colours rebuilt when the scheme's colours change, instead
of hard-coded colours. Part outlines follow the scheme's outline colour
or the fill darkened as WinForms darkens it. There is no Options screen
to change the scheme yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-11 21:23:48 -05:00
1 parent cc3711c11c
commit 641e07b766
4 files changed
+117 -22

No files matched your search

+22 -22
View File
@@ -16,18 +16,13 @@ using Vector = OpenNest.Geometry.Vector;
namespace OpenNest.Avalonia.Controls
{
/// <summary>
/// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
/// wheel, fits on a double click, and highlights the part under the cursor.
/// Draws one plate with Skia in the colours of <see cref="Scheme"/>: pans with a left or
/// middle drag, zooms at the cursor with the wheel, fits on a double click, and highlights the
/// part under the cursor.
/// </summary>
public sealed class PlateCanvas : Control
{
private static readonly SKColor BackgroundColor = new(169, 169, 169);
private static readonly SKColor PlateFillColor = new(245, 245, 245);
private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
private static readonly SKColor LeadColor = new(255, 0, 0);
private static readonly SKColor EtchColor = new(0, 150, 60);
private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
// Paths are built once per program and only read afterwards, so the render thread may draw
// them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
@@ -52,6 +47,9 @@ namespace OpenNest.Avalonia.Controls
}
}
/// <summary>The colours drawn with: the application's active scheme unless set.</summary>
public ColorScheme Scheme { get; set; } = ColorScheme.Default;
public Part HoveredPart { get; private set; }
public Part SelectedPart { get; private set; }
@@ -131,21 +129,23 @@ namespace OpenNest.Avalonia.Controls
if (fitPending && Fit())
global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
var palette = SkiaSchemePalette.For(Scheme ?? ColorScheme.Default);
var items = new List<PartItem>();
if (plate != null)
{
for (var i = 0; i < plate.Parts.Count; i++)
{
var part = plate.Parts[i];
var color = ToSkColor(part.BaseDrawing.Color);
items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
color, palette.PartOutline(color), (i + 1).ToString(),
ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
}
}
context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
plate?.EdgeSpacing, WorldMatrix, items));
plate?.EdgeSpacing, WorldMatrix, items, palette));
}
private static SKColor ToSkColor(System.Drawing.Color color) =>
@@ -257,7 +257,7 @@ namespace OpenNest.Avalonia.Controls
}
private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
SKColor Outline, string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
private sealed class PlateDrawOperation : ICustomDrawOperation
{
@@ -265,10 +265,12 @@ namespace OpenNest.Avalonia.Controls
private readonly Spacing? edgeSpacing;
private readonly SKMatrix matrix;
private readonly List<PartItem> items;
private readonly SkiaSchemePalette palette;
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
List<PartItem> items)
List<PartItem> items, SkiaSchemePalette palette)
{
this.palette = palette;
Bounds = bounds;
this.plateRect = plateRect;
this.edgeSpacing = edgeSpacing;
@@ -295,7 +297,7 @@ namespace OpenNest.Avalonia.Controls
var canvas = api.SkCanvas;
canvas.Save();
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
canvas.Clear(BackgroundColor);
canvas.Clear(palette.Background);
if (plateRect != null)
Draw(canvas);
@@ -315,16 +317,16 @@ namespace OpenNest.Avalonia.Controls
var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
(float)plateRect.Right, (float)plateRect.Top);
fill.Color = PlateFillColor;
fill.Color = palette.PlateFill;
canvas.DrawRect(plate, fill);
if (edgeSpacing is { } spacing)
{
stroke.Color = EdgeSpacingColor;
stroke.Color = palette.EdgeSpacing;
canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
plate.Bottom - (float)spacing.Top), stroke);
}
stroke.Color = PlateOutlineColor;
stroke.Color = palette.PlateOutline;
canvas.DrawRect(plate, stroke);
var view = canvas.LocalClipBounds;
@@ -337,13 +339,14 @@ namespace OpenNest.Avalonia.Controls
canvas.Save();
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
fill.Color = item.Selected ? SelectedColor
fill.Color = item.Selected ? palette.SelectedFill
: item.Hovered ? Lighten(item.Color) : item.Color;
canvas.DrawPath(item.Paths.Fill, fill);
stroke.Color = EtchColor;
stroke.Color = palette.Etch;
canvas.DrawPath(item.Paths.Etch, stroke);
stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
// Keep real cuts visible even where an etch overlaps them.
stroke.Color = item.Selected ? palette.SelectedOutline : item.Outline;
canvas.DrawPath(item.Paths.Cut, stroke);
// As on the desktop, only manually placed lead-ins are shown.
if (item.ShowLeads)
@@ -377,9 +380,6 @@ namespace OpenNest.Avalonia.Controls
}
}
private static SKColor Darken(SKColor c) =>
new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
private static SKColor Lighten(SKColor c) =>
new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
(byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
+4
View File
@@ -50,6 +50,8 @@ namespace OpenNest.Avalonia
var engineWarning = EngineSelection.LoadSavedSelection();
if (!string.IsNullOrEmpty(engineWarning))
notice = engineWarning;
// The colour scheme chosen in the WinForms app's options, from the shared user settings.
ColorSchemeRegistry.ApplySaved(Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath));
tabs.Changed += (_, _) => ShowActiveTab();
KeyBindings.Add(new KeyBinding
{
@@ -99,6 +101,8 @@ namespace OpenNest.Avalonia
var reader = new NestReader(file);
var document = new Document { Nest = reader.Read() };
warnings = reader.Warnings;
// The view follows the application's scheme, not the one the file was saved under.
ColorSchemeRegistry.RecolorDrawings(document.Nest);
return document;
});
if (!views.ContainsKey(tab))
@@ -0,0 +1,79 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using SkiaSharp;
using DrawingColor = System.Drawing.Color;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// A colour scheme's colours as Skia colours, for the plate canvas. Immutable, so the render
/// thread may read one while the UI thread changes the scheme; <see cref="For"/> returns the
/// cached palette until the scheme's colours change.
/// </summary>
internal sealed class SkiaSchemePalette
{
// The WinForms view's translucent selection fill, used when the scheme sets none.
private static readonly DrawingColor LegacySelectedColor = DrawingColor.FromArgb(90, 150, 200, 255);
private static readonly ConditionalWeakTable<ColorScheme, SkiaSchemePalette> Cache = new();
private readonly DrawingColor partOutline;
private readonly ConcurrentDictionary<SKColor, SKColor> outlines = new();
private SkiaSchemePalette(ColorScheme scheme)
{
partOutline = scheme.PartOutlineColor;
Version = scheme.Version;
Background = ToSk(scheme.BackgroundColor);
PlateFill = ToSk(scheme.LayoutFillColor);
PlateOutline = ToSk(scheme.LayoutOutlineColor);
EdgeSpacing = ToSk(scheme.EdgeSpacingColor);
Etch = ToSk(scheme.EtchColor);
var selected = scheme.SelectedPartColor.IsEmpty ? LegacySelectedColor : scheme.SelectedPartColor;
SelectedFill = ToSk(selected);
// As WinForms: the scheme's outline, else the legacy selection fill darkened.
SelectedOutline = ToSk(scheme.PartOutlineColor.IsEmpty
? ColorShade.Dark(LegacySelectedColor)
: scheme.PartOutlineColor);
}
private int Version { get; }
public SKColor Background { get; }
public SKColor PlateFill { get; }
public SKColor PlateOutline { get; }
public SKColor EdgeSpacing { get; }
public SKColor Etch { get; }
public SKColor SelectedFill { get; }
public SKColor SelectedOutline { get; }
/// <summary>The palette for <paramref name="scheme"/>'s current colours.</summary>
public static SkiaSchemePalette For(ColorScheme scheme)
{
lock (Cache)
{
if (!Cache.TryGetValue(scheme, out var palette) || palette.Version != scheme.Version)
{
palette = new SkiaSchemePalette(scheme);
Cache.AddOrUpdate(scheme, palette);
}
return palette;
}
}
/// <summary>A part's outline for its fill: the scheme's outline colour, or the fill darkened.</summary>
public SKColor PartOutline(SKColor fill) =>
outlines.GetOrAdd(fill, f => ToSk(partOutline.IsEmpty
? ColorShade.Dark(DrawingColor.FromArgb(f.Alpha, f.Red, f.Green, f.Blue))
: partOutline));
public static SKColor ToSk(DrawingColor color) => new(color.R, color.G, color.B, color.A);
}
}
+12
View File
@@ -63,6 +63,18 @@ fill hue falls in the etch green band. Selected rendering has separate bitmap te
Custom drawing colors are not automatically contrast-corrected. Existing scheme
names, saved choices, custom palettes and geometry remain unchanged.
## Shared code and the Avalonia app
The schemes, their JSON format, the `Schemes/` folder and the recolor rule live in
`OpenNest.Desktop` (`ColorScheme`, `ColorSchemeRegistry`, `ColorSchemeSerializer`,
and `ColorShade`, a port of `ControlPaint.Dark` for the legacy part outline), so
both desktop apps share them; `Schemes/*.json` in that project copies beside both
apps. WinForms draws with per-thread GDI+ pens from `GdiSchemeTools`; the Avalonia
plate canvas draws with Skia colours from `SkiaSchemePalette`. The Avalonia app
applies the scheme saved as `ActiveColorScheme` in the shared user-settings file
when it starts, and recolors a nest's drawings when it opens one; it has no
Options screen to change the scheme yet.
## Verification
On Windows, run: