From 641e07b766e90a6739fb6fe21c5b90c389f6631e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Nov 2026 21:22:49 -0500 Subject: [PATCH] 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 --- OpenNest.Avalonia/Controls/PlateCanvas.cs | 44 +++++------ OpenNest.Avalonia/MainWindow.axaml.cs | 4 + .../Rendering/SkiaSchemePalette.cs | 79 +++++++++++++++++++ docs/nesting-view-colors.md | 12 +++ 4 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs diff --git a/OpenNest.Avalonia/Controls/PlateCanvas.cs b/OpenNest.Avalonia/Controls/PlateCanvas.cs index 5704acad..f39e98f9 100644 --- a/OpenNest.Avalonia/Controls/PlateCanvas.cs +++ b/OpenNest.Avalonia/Controls/PlateCanvas.cs @@ -16,18 +16,13 @@ using Vector = OpenNest.Geometry.Vector; namespace OpenNest.Avalonia.Controls { /// - /// 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 : 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. /// 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 } } + /// The colours drawn with: the application's active scheme unless set. + 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(); 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 items; + private readonly SkiaSchemePalette palette; public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix, - List items) + List 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); diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs index 6a6828b7..fe89c532 100644 --- a/OpenNest.Avalonia/MainWindow.axaml.cs +++ b/OpenNest.Avalonia/MainWindow.axaml.cs @@ -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)) diff --git a/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs b/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs new file mode 100644 index 00000000..23294324 --- /dev/null +++ b/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using SkiaSharp; +using DrawingColor = System.Drawing.Color; + +namespace OpenNest.Avalonia.Rendering +{ + /// + /// 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; returns the + /// cached palette until the scheme's colours change. + /// + 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 Cache = new(); + + private readonly DrawingColor partOutline; + private readonly ConcurrentDictionary 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; } + + /// The palette for 's current colours. + 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; + } + } + + /// A part's outline for its fill: the scheme's outline colour, or the fill darkened. + 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); + } +} diff --git a/docs/nesting-view-colors.md b/docs/nesting-view-colors.md index 32461606..1e6cb933 100644 --- a/docs/nesting-view-colors.md +++ b/docs/nesting-view-colors.md @@ -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: