Files
riojoy/src/RioJoy.Overlay/SkiaOverlayRenderer.cs
T
CydandClaude Opus 4.8 fe87c79f55 net48 port (test branch): retarget all projects to .NET Framework 4.8
Retargets RioJoy.Core/Overlay/Tray + tests from net8.0-windows to net48 so
the app can be tested as a framework-dependent build (relies on the in-box
.NET Framework 4.8 on Windows 10/11). Builds clean; all 241 tests pass.

Polyfills (no behavior change):
- PolySharp source generator for init/records/Index/Range/required members.
- System.Memory, System.Text.Json, Microsoft.Bcl.HashCode,
  System.Threading.Channels (tests) NuGet packages.
- Compat/Net48Polyfills.cs: GetValueOrDefault, KeyValuePair.Deconstruct,
  Math.Clamp; tests/TestPolyfills.cs: Task.WaitAsync.

Source adjustments for APIs absent on net48:
- ArgumentNullException/ArgumentException.ThrowIf* inlined to manual guards.
- Convert.ToHexString, Encoding.Latin1, Environment.ProcessPath,
  ApplicationConfiguration.Initialize, Enum.GetNames<T>/GetValues<T>,
  string.StartsWith(char), string.Split(char, opts), TextBox.PlaceholderText,
  PeriodicTimer, Memory-based Stream Read/WriteAsync, array range-slicing.
- Dropped [SupportedOSPlatform] hints (net48 is single-platform).

deploy/build-package.ps1: publish framework-dependent (no self-contained).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:34:47 -05:00

133 lines
5.3 KiB
C#

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)
{
if (template is null) throw new ArgumentNullException(nameof(template));
if (labels is null) throw new ArgumentNullException(nameof(labels));
if (baseImage is null) throw new ArgumentNullException(nameof(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)
{
if (template is null) throw new ArgumentNullException(nameof(template));
if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(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);
}
}