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>
134 lines
5.2 KiB
C#
134 lines
5.2 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.RegularExpressions;
|
|
|
|
// Extracts the cockpit label cells from the GIMP source art (riojoy.xcf) into a
|
|
// regions.json in OverlayTemplate shape: each named text layer becomes a region
|
|
// (Name = layer name, geometry = layer box, font/size from the gimp-text-layer
|
|
// parasite). Replaces eyeballing coordinates out of GIMP by hand.
|
|
//
|
|
// dotnet run --project tools/XcfRegionExtract [-- <in.xcf> <out.json>]
|
|
//
|
|
// Defaults assume it is run from the repo root. XCF is big-endian; pointers are
|
|
// 32-bit for versions < 11. Only layer metadata is read (no tile/pixel data), so
|
|
// tile compression does not matter.
|
|
|
|
string inPath = args.Length > 0 ? args[0] : "docs/reference/customBackground/riojoy.xcf";
|
|
string outPath = args.Length > 1 ? args[1] : "docs/reference/customBackground/regions.json";
|
|
// Base cockpit image (the wallpaper background), a sibling of regions.json.
|
|
string baseImage = args.Length > 2 ? args[2] : "riojoy.png";
|
|
|
|
byte[] d = File.ReadAllBytes(inPath);
|
|
int pos = 0;
|
|
|
|
uint U32() { uint v = (uint)((d[pos] << 24) | (d[pos + 1] << 16) | (d[pos + 2] << 8) | d[pos + 3]); pos += 4; return v; }
|
|
int I32() => (int)U32();
|
|
ulong U64() { ulong v = 0; for (int i = 0; i < 8; i++) v = (v << 8) | d[pos++]; return v; }
|
|
|
|
string magic = Encoding.ASCII.GetString(d, 0, 9); // "gimp xcf "
|
|
string ver = Encoding.ASCII.GetString(d, 9, 4); // "file" (=v0) or "vNNN"
|
|
if (magic != "gimp xcf ") { Console.Error.WriteLine($"Not an XCF file: '{magic}'"); return 1; }
|
|
bool ptr64 = ver[0] == 'v' && int.TryParse(ver.AsSpan(1, 3), out int vn) && vn >= 11;
|
|
long Ptr() => ptr64 ? (long)U64() : U32();
|
|
|
|
pos = 14;
|
|
int width = (int)U32(), height = (int)U32();
|
|
_ = U32(); // base type
|
|
Console.WriteLine($"xcf ver='{ver}' canvas={width}x{height}");
|
|
|
|
string XcfString()
|
|
{
|
|
uint len = U32();
|
|
if (len == 0) return "";
|
|
string s = Encoding.UTF8.GetString(d, pos, (int)len - 1); // length includes trailing NUL
|
|
pos += (int)len;
|
|
return s;
|
|
}
|
|
|
|
void SkipProps()
|
|
{
|
|
while (true) { uint t = U32(); uint l = U32(); if (t == 0) break; pos += (int)l; }
|
|
}
|
|
|
|
pos = 26; // header(14) + width(4) + height(4) + base(4)
|
|
SkipProps(); // image property list
|
|
var layerPtrs = new List<long>();
|
|
while (true) { long p = Ptr(); if (p == 0) break; layerPtrs.Add(p); }
|
|
|
|
var fontRx = new Regex("\\(font \"([^\"]*)\"\\)");
|
|
var sizeRx = new Regex("\\(font-size ([0-9.]+)");
|
|
var colorRx = new Regex("\\(color \\(color-rgb ([0-9.]+) ([0-9.]+) ([0-9.]+)\\)\\)");
|
|
|
|
static string? ToHex(Match m)
|
|
{
|
|
if (!m.Success) return null;
|
|
int Byte(int g) => (int)Math.Round(double.Parse(m.Groups[g].Value, CultureInfo.InvariantCulture) * 255);
|
|
return $"#{Byte(1):X2}{Byte(2):X2}{Byte(3):X2}";
|
|
}
|
|
|
|
// Cockpit-specific orientation: these cells' labels read 90° counter-clockwise
|
|
// (the vertical button column b-10..b-1F and the center title a-00). GIMP doesn't
|
|
// record a simple text-layer rotation, so it is applied here by name.
|
|
var rotateCcw90 = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "a-00" };
|
|
for (int a = 0x10; a <= 0x1F; a++) rotateCcw90.Add($"b-{a:X2}");
|
|
|
|
var regions = new List<object>();
|
|
foreach (long lp in layerPtrs)
|
|
{
|
|
pos = (int)lp;
|
|
uint lw = U32(), lh = U32(); _ = U32(); // width, height, type
|
|
string name = XcfString();
|
|
int dx = 0, dy = 0; string? parasite = null;
|
|
|
|
while (true)
|
|
{
|
|
uint t = U32(); uint l = U32();
|
|
if (t == 0) break;
|
|
int next = pos + (int)l;
|
|
if (t == 15) { dx = I32(); dy = I32(); } // PROP_OFFSETS
|
|
else if (t == 21) // PROP_PARASITES
|
|
{
|
|
while (pos < next)
|
|
{
|
|
string pname = XcfString();
|
|
_ = U32(); // flags
|
|
uint psize = U32();
|
|
string pdata = Encoding.UTF8.GetString(d, pos, (int)psize); pos += (int)psize;
|
|
if (pname == "gimp-text-layer") parasite = pdata;
|
|
}
|
|
}
|
|
pos = next;
|
|
}
|
|
|
|
if (parasite is null) continue; // only text layers are label cells
|
|
string font = fontRx.Match(parasite) is { Success: true } fm ? fm.Groups[1].Value : "Sans";
|
|
string sizeStr = sizeRx.Match(parasite) is { Success: true } sm ? sm.Groups[1].Value : "12";
|
|
double sizePx = double.TryParse(sizeStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 12;
|
|
string? colorHex = ToHex(colorRx.Match(parasite));
|
|
|
|
regions.Add(new
|
|
{
|
|
Name = name,
|
|
X = dx,
|
|
Y = dy,
|
|
Width = (int)lw,
|
|
Height = (int)lh,
|
|
FontFamily = string.IsNullOrEmpty(font) ? "Sans" : font,
|
|
FontSizePx = sizePx,
|
|
ColorHex = colorHex,
|
|
RotationDegrees = rotateCcw90.Contains(name) ? 90.0 : (double?)null,
|
|
});
|
|
}
|
|
|
|
var template = new { BaseImagePath = baseImage, Width = width, Height = height, Regions = regions };
|
|
var opts = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
File.WriteAllText(outPath, JsonSerializer.Serialize(template, opts));
|
|
Console.WriteLine($"Wrote {regions.Count} regions -> {outPath}");
|
|
return 0;
|