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>
133 lines
5.3 KiB
C#
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);
|
|
}
|
|
}
|