Phase 7: cockpit overlay generator, region extraction, and profile editor

Overlay generator (pure, tested) — RioJoy.Core/Overlay:
- FontFitter ports the legacy calc-fontsize auto-fit search (validated against a
  brute-force oracle); OverlayLayoutEngine ports create-data-layer's fit + h/v
  justification and adds per-region 90 deg CCW rotation; OverlayTemplate/Region +
  OverlayTemplateStore hold cell geometry/colour/rotation (regions.json).
- GoobieDataImporter parses the legacy .data label sheet into label rows.
- src/RioJoy.Overlay: SkiaSharp rasterizer (SkiaTextMeasurer shared with the engine
  so measured layout == drawn output; SkiaOverlayRenderer -> PNG;
  ProfileWallpaperGenerator). Verified end-to-end on the real cockpit art
  (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests.
- RioJoy.Tray/WallpaperApplier applies via SystemParametersInfo; RioCoordinator
  generates+applies on profile activation (opt-in via AppConfig.OverlayTemplatePath).

Region geometry — tools/XcfRegionExtract parses riojoy.xcf (a GIMP-format binary
reader, no GIMP needed) into the 119-cell regions.json: per-layer offsets/size/
font/colour, with 90 deg CCW rotation on a-00 + b-10..b-1F. Base image riojoy.png.
NOTE: the wallpaper positions are a VGA chroma-split display artifact (6 displays),
not the cockpit's logical layout.

Mapping editor (first cut) — RioJoy.Tray/Editor/ProfileEditorForm renders the
config sheet's logical grid (SheetLayout parses the sheet CSV export). The iRIO
word is edited via ButtonBinding <-> RioMapEntry.Create (no hex bit-twiddling), with
a context-sensitive picker: keyboard keys by name (KeyCatalog VK names), joystick
Button N, hat direction, mouse/RIO-command enum names; modifiers only for keyboard.
Opened from the tray ("Edit profile..."). The sheet-grid layout is a starting point
that needs rework from a better-formatted source.

~220 xUnit tests green. Docs (PLAN.md, README) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-27 17:13:48 -05:00
co-authored by Claude Opus 4.8
parent 2deeb374ae
commit 8d2d0b71aa
47 changed files with 3866 additions and 21 deletions
@@ -0,0 +1,56 @@
using RioJoy.Core.Overlay;
using SkiaSharp;
namespace RioJoy.Overlay;
/// <summary>
/// Generates a profile's cockpit wallpaper: renders a label set onto the overlay
/// template's base image and writes a PNG. Resolves the template's
/// <see cref="OverlayTemplate.BaseImagePath"/> relative to the template file's
/// directory (so a <c>regions.json</c> + sibling <c>riojoy.png</c> work as a unit).
/// The runtime calls this on profile activation; an editor can call it for preview.
/// </summary>
public sealed class ProfileWallpaperGenerator
{
private readonly SkiaOverlayRenderer _renderer;
public ProfileWallpaperGenerator(SkiaOverlayRenderer? renderer = null) =>
_renderer = renderer ?? new SkiaOverlayRenderer();
/// <summary>
/// Render <paramref name="labels"/> onto the template's base image and write a
/// PNG to <paramref name="outputPath"/>; returns the output path.
/// </summary>
/// <param name="templateDirectory">
/// Directory the template's relative <see cref="OverlayTemplate.BaseImagePath"/>
/// is resolved against (normally the folder holding the regions.json).
/// </param>
public string Generate(
OverlayTemplate template,
string templateDirectory,
IReadOnlyDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(labels);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
string basePath = Path.IsPathRooted(template.BaseImagePath)
? template.BaseImagePath
: Path.Combine(templateDirectory, template.BaseImagePath);
using SKBitmap baseImage = SKBitmap.Decode(basePath)
?? throw new InvalidOperationException($"Could not decode base image: {basePath}");
byte[] png = _renderer.RenderToPng(template, labels, baseImage, options);
string? dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(outputPath, png);
return outputPath;
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Platforms>x64</Platforms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- SkiaSharp: cross-platform 2D rasterizer used to render the wallpaper. -->
<PackageReference Include="SkiaSharp" Version="2.88.8" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="2.88.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RioJoy.Core\RioJoy.Core.csproj" />
</ItemGroup>
</Project>
+132
View File
@@ -0,0 +1,132 @@
using RioJoy.Core.Overlay;
using SkiaSharp;
namespace RioJoy.Overlay;
/// <summary>
/// Renders a cockpit wallpaper: lays out per-region label text with
/// <see cref="OverlayLayoutEngine"/>, then draws each label onto the base image
/// with SkiaSharp. The modern replacement for the legacy GIMP/Script-Fu pipeline
/// (docs/reference/customBackground/sg-goobie-MFD.scm). Because it shares its
/// <see cref="SkiaTextMeasurer"/> with the layout engine, drawn positions match the
/// measured extents exactly.
/// </summary>
public sealed class SkiaOverlayRenderer
{
private readonly SkiaTextMeasurer _measurer;
private readonly OverlayLayoutEngine _engine;
private readonly SKColor _defaultColor;
/// <param name="defaultColorHex">Label color when a region sets none (default white).</param>
public SkiaOverlayRenderer(string defaultColorHex = "#FFFFFF")
{
_measurer = new SkiaTextMeasurer();
_engine = new OverlayLayoutEngine(_measurer);
_defaultColor = SKColor.Parse(defaultColorHex);
}
/// <summary>
/// Render labels onto <paramref name="baseImage"/> and return the result as a
/// new bitmap (the input is not modified). Caller owns the returned bitmap.
/// </summary>
public SKBitmap Render(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(labels);
ArgumentNullException.ThrowIfNull(baseImage);
options ??= new OverlayLayoutOptions();
var output = baseImage.Copy();
using var canvas = new SKCanvas(output);
foreach (PlacedLabel label in _engine.Layout(template, labels, options))
{
OverlayRegion? region = template.FindRegion(label.RegionName);
SKColor color = region?.ColorHex is { } hex ? SKColor.Parse(hex) : _defaultColor;
using var paint = new SKPaint
{
Typeface = _measurer.Typeface(label.FontFamily),
TextSize = (float)label.FontSizePx,
Color = color,
IsAntialias = true,
};
// PlacedLabel.Y is the top of the text box; Skia draws from the baseline.
float baseline = (float)label.Y - paint.FontMetrics.Ascent; // ascent is negative
// Rotated labels: turn the canvas about the cell center (the engine
// centered the text box there). RotationDegrees is counter-clockwise;
// Skia's RotateDegrees is clockwise-positive, so negate.
if (label.RotationDegrees != 0 && region is not null)
{
float cx = (float)(region.X + region.Width / 2);
float cy = (float)(region.Y + region.Height / 2);
canvas.Save();
canvas.RotateDegrees(-(float)label.RotationDegrees, cx, cy);
canvas.DrawText(label.Text, (float)label.X, baseline, paint);
canvas.Restore();
continue;
}
bool clip = options.SizeMode == OverlaySizeMode.Crop && region is not null;
if (clip)
{
canvas.Save();
canvas.ClipRect(new SKRect(
(float)region!.X, (float)region.Y,
(float)(region.X + region.Width), (float)(region.Y + region.Height)));
}
canvas.DrawText(label.Text, (float)label.X, baseline, paint);
if (clip)
canvas.Restore();
}
canvas.Flush();
return output;
}
/// <summary>Render and encode to PNG bytes.</summary>
public byte[] RenderToPng(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
SKBitmap baseImage,
OverlayLayoutOptions? options = null)
{
using SKBitmap bmp = Render(template, labels, baseImage, options);
using SKData data = bmp.Encode(SKEncodedImageFormat.Png, 100);
return data.ToArray();
}
/// <summary>
/// Render using the template's <see cref="OverlayTemplate.BaseImagePath"/> and
/// write a PNG to <paramref name="outputPath"/>.
/// </summary>
public void RenderToFile(
OverlayTemplate template,
IReadOnlyDictionary<string, string> labels,
string outputPath,
OverlayLayoutOptions? options = null)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
if (string.IsNullOrWhiteSpace(template.BaseImagePath))
throw new InvalidOperationException("OverlayTemplate.BaseImagePath is not set.");
using SKBitmap baseImage = SKBitmap.Decode(template.BaseImagePath)
?? throw new InvalidOperationException($"Could not decode base image: {template.BaseImagePath}");
byte[] png = RenderToPng(template, labels, baseImage, options);
string? dir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(outputPath, png);
}
}
+35
View File
@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using RioJoy.Core.Overlay;
using SkiaSharp;
namespace RioJoy.Overlay;
/// <summary>
/// <see cref="ITextMeasurer"/> backed by SkiaSharp — the real metrics the
/// <see cref="SkiaOverlayRenderer"/> draws with, so measured layout and rendered
/// output agree. Width is the glyph advance; height is the logical line height
/// (descent ascent), which keeps vertical centering stable across labels
/// regardless of which glyphs each one happens to contain.
/// </summary>
public sealed class SkiaTextMeasurer : ITextMeasurer
{
private readonly ConcurrentDictionary<string, SKTypeface> _typefaces = new();
internal SKTypeface Typeface(string fontFamily) =>
_typefaces.GetOrAdd(fontFamily, f => SKTypeface.FromFamilyName(f) ?? SKTypeface.Default);
public TextExtent Measure(string text, string fontFamily, double fontSizePx)
{
using var paint = new SKPaint
{
Typeface = Typeface(fontFamily),
TextSize = (float)fontSizePx,
IsAntialias = true,
};
double width = paint.MeasureText(text);
SKFontMetrics m = paint.FontMetrics;
double height = m.Descent - m.Ascent; // ascent is negative
return new TextExtent(width, height);
}
}