From e60d551f391d569542da53c03aeba9af80d5d4ca Mon Sep 17 00:00:00 2001 From: Cyd Date: Mon, 6 Jul 2026 09:37:29 -0500 Subject: [PATCH] Phase 7: live encoder gauges in the profile editor RioRuntime raises AxesUpdated with the calibrated AxisOutputs after every analog reply (independent of the joystick sink, so the gauges work while editor input routing is suppressed). The editor strip now renders the values live: Z and the L/R pedals (Rx/Ry) fill bottom-up, Rz deflects from a center tick, and the X/Y box tracks the stick as a dot over a crosshair. Fraction/deflection math is pure in Core.Editing.AxisGauges (clamped, unit-tested); the canvas repaints only the strip and only on change. Verified by offline form screenshots against injected values. Co-Authored-By: Claude Fable 5 --- docs/PLAN.md | 9 ++- src/RioJoy.Core/Editing/AxisGauges.cs | 21 +++++++ src/RioJoy.Core/RioRuntime.cs | 9 +++ src/RioJoy.Tray/Editor/PanelCanvas.cs | 17 ++++++ src/RioJoy.Tray/Editor/PanelView.cs | 59 ++++++++++++++++--- src/RioJoy.Tray/Editor/ProfileEditorForm.cs | 5 ++ src/RioJoy.Tray/TrayApplicationContext.cs | 2 + .../Editing/AxisGaugesTests.cs | 32 ++++++++++ tests/RioJoy.Core.Tests/RioRuntimeTests.cs | 27 +++++++++ 9 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 src/RioJoy.Core/Editing/AxisGauges.cs create mode 100644 tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs diff --git a/docs/PLAN.md b/docs/PLAN.md index f726593..c52925a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -280,8 +280,13 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline `ButtonBinding` ↔ `RioMapEntry.Create` — no hex. The value field is a context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N, hat direction, mouse/RIO-command enum); modifiers enable only for keyboard. - Opened from the tray ("Edit profile…"); Save persists the profile. - ⏳ Still to refine: live encoder gauges, showing each button's assigned key as a + Opened from the tray ("Edit profile…"); Save persists the profile. The encoder + gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values + (post invert/deadzone/mix — exactly what the virtual joystick receives) into the + strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects + from its center tick, and the X/Y box tracks the stick as a dot (pure fraction + math in `RioJoy.Core.Editing.AxisGauges`, unit-tested). + ⏳ Still to refine: showing each button's assigned key as a caption, grouping a button's two bank addresses, and clone-from-existing. - The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with importers for each. diff --git a/src/RioJoy.Core/Editing/AxisGauges.cs b/src/RioJoy.Core/Editing/AxisGauges.cs new file mode 100644 index 0000000..6221cda --- /dev/null +++ b/src/RioJoy.Core/Editing/AxisGauges.cs @@ -0,0 +1,21 @@ +using RioJoy.Core.Calibration; + +namespace RioJoy.Core.Editing; + +/// +/// Pure math for the editor's live encoder gauges: converts a calibrated axis +/// value (0..32766, center 16383) into a normalized fill fraction or a +/// signed center deflection, clamped so out-of-range values can't overdraw a +/// gauge. UI-free so the conversions are unit-tested; the panel renderer only +/// scales these to pixels. +/// +public static class AxisGauges +{ + /// Fill fraction 0..1 of an axis value (0 = empty, Max = full). + public static double Fraction(int value) => + Math.Min(1.0, Math.Max(0.0, value / (double)AxisOutputs.Max)); + + /// Signed deflection -1..+1 from the axis center (0 = centered). + public static double Deflection(int value) => + Math.Min(1.0, Math.Max(-1.0, (value - AxisOutputs.Center) / (double)AxisOutputs.Center)); +} diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs index fb1d42a..1744b73 100644 --- a/src/RioJoy.Core/RioRuntime.cs +++ b/src/RioJoy.Core/RioRuntime.cs @@ -56,6 +56,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable /// public event Action? ButtonActivity; + /// + /// Raised with the six calibrated axis values after every analog reply — + /// independent of the joystick sink, so the editor can show live gauges even + /// while input routing is suppressed. + /// + public event Action? AxesUpdated; + /// Raised with the firmware version when a arrives. public event Action? VersionReceived; @@ -129,6 +136,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable { // Output is best-effort: a faulty joystick sink must not kill the link. } + + AxesUpdated?.Invoke(axes); } private void OnPacket(RioPacket packet) diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs index 42e07ba..8a32e20 100644 --- a/src/RioJoy.Tray/Editor/PanelCanvas.cs +++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs @@ -1,3 +1,4 @@ +using RioJoy.Core.Calibration; using RioJoy.Core.Editing; namespace RioJoy.Tray.Editor; @@ -31,6 +32,7 @@ internal sealed class PanelCanvas : Control public Func? CalibrationProvider { get; set; } private readonly HashSet _livePressed = new(); + private AxisOutputs? _axes; public int? SelectedAddress { get; private set; } @@ -43,6 +45,20 @@ internal sealed class PanelCanvas : Control Invalidate(); } + /// + /// Show the latest calibrated axis values on the encoder-gauge strip. Arrives at + /// the analog poll rate (~18 Hz), so only the strip is repainted, and only when a + /// value actually changed. + /// + public void SetAxes(AxisOutputs axes) + { + if (_axes is { } p && p.X == axes.X && p.Y == axes.Y && p.Z == axes.Z && + p.Rx == axes.Rx && p.Ry == axes.Ry && p.Rz == axes.Rz) + return; + _axes = axes; + Invalidate(new Rectangle(0, 0, Width, PanelView.TopStrip)); + } + /// Raised when a [JoyStick] toggle cell is clicked. public event Action? CalibrationToggled; @@ -66,6 +82,7 @@ internal sealed class PanelCanvas : Control a => FunctionProvider?.Invoke(a), a => LitProvider?.Invoke(a) ?? false, _livePressed.Contains, + _axes, SelectedAddress); PanelView.PaintCalibration(e.Graphics, s => CalibrationProvider?.Invoke(s) ?? false); } diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs index edf414a..fc931fc 100644 --- a/src/RioJoy.Tray/Editor/PanelView.cs +++ b/src/RioJoy.Tray/Editor/PanelView.cs @@ -1,3 +1,4 @@ +using RioJoy.Core.Calibration; using RioJoy.Core.Editing; namespace RioJoy.Tray.Editor; @@ -74,6 +75,7 @@ public static class PanelView Func function, Func lit, Func live, + AxisOutputs? axes, int? selected) { g.Clear(Color.FromArgb(28, 28, 28)); @@ -82,7 +84,7 @@ public static class PanelView using var subFont = new Font("Segoe UI", 6.75f); using var groupPen = new Pen(Color.FromArgb(80, 80, 80)); - DrawEncoderStrip(g, titleFont); + DrawEncoderStrip(g, titleFont, axes); // Group frames + titles. foreach (PanelGroup grp in groups) @@ -150,10 +152,11 @@ public static class PanelView } } - private static void DrawEncoderStrip(Graphics g, Font font) + private static void DrawEncoderStrip(Graphics g, Font font, AxisOutputs? axes) { using var pen = new Pen(Color.FromArgb(120, 160, 120)); using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20)); + using var fill = new SolidBrush(Color.FromArgb(52, 128, 70)); // Centered over the Upper Middle MFD block and arranged to match the physical // cockpit: Z on the left and the X/Y stick graph on the right (both full @@ -164,21 +167,61 @@ public static class PanelView const int bar = 30; // stroke thickness, matching Z's width / Rz bar int baseX = 6 * CellW - 100; // cluster is 200 wide; 6*CellW centers the MFD int uX = baseX + 34, uW = 82; + var zBox = new Rectangle(baseX, top, bar, fullH); + var lBox = new Rectangle(uX, top, bar, fullH); // left arm of the U + var rBox = new Rectangle(uX + uW - bar, top, bar, fullH); // right arm of the U + var rzBox = new Rectangle(uX, top + fullH, uW, bar); // bottom bar, below L & R (no overlap) + var xyBox = new Rectangle(baseX + 120, top, 80, fullH); (string Label, Rectangle Box)[] boxes = { - ("Z", new Rectangle(baseX, top, bar, fullH)), - ("L", new Rectangle(uX, top, bar, fullH)), // left arm of the U - ("R", new Rectangle(uX + uW - bar, top, bar, fullH)), // right arm of the U - ("Rz", new Rectangle(uX, top + fullH, uW, bar)), // bottom bar, below L & R (no overlap) - ("X / Y", new Rectangle(baseX + 120, top, 80, fullH)), + ("Z", zBox), ("L", lBox), ("R", rBox), ("Rz", rzBox), ("X / Y", xyBox), }; + + foreach (var (_, box) in boxes) + g.FillRectangle(gauge, box); + + // Live values from the RIO's analog stream (null when the link is down). + // The gauges show the calibrated outputs — post invert/deadzone/mix — i.e. + // exactly what the virtual joystick receives. + if (axes is { } a) + { + // Z (throttle) and the L/R pedals (Rx/Ry) fill bottom-up. + foreach (var (box, value) in new[] { (zBox, a.Z), (lBox, a.Rx), (rBox, a.Ry) }) + { + int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value)); + if (h > 0) + g.FillRectangle(fill, box.X, box.Bottom - h, box.Width, h); + } + + // Rz (mixed rudder) deflects left/right from the bar's center. + double d = AxisGauges.Deflection(a.Rz); + int cx = rzBox.X + rzBox.Width / 2; + int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d)); + if (w > 0) + g.FillRectangle(fill, d < 0 ? cx - w : cx, rzBox.Y, w, rzBox.Height); + } + + // Reference marks: Rz center tick + X/Y crosshair (drawn even when idle). + g.DrawLine(pen, rzBox.X + rzBox.Width / 2, rzBox.Y, rzBox.X + rzBox.Width / 2, rzBox.Bottom); + g.DrawLine(pen, xyBox.X, xyBox.Y + xyBox.Height / 2, xyBox.Right, xyBox.Y + xyBox.Height / 2); + g.DrawLine(pen, xyBox.X + xyBox.Width / 2, xyBox.Y, xyBox.X + xyBox.Width / 2, xyBox.Bottom); + foreach (var (lbl, box) in boxes) { - g.FillRectangle(gauge, box); g.DrawRectangle(pen, box); TextRenderer.DrawText(g, lbl, font, box, Color.Gainsboro, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } + + // The X/Y stick dot goes on top of the crosshair and label + // (0 = left/top, matching the HID axis convention). + if (axes is { } live) + { + int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.X) * (xyBox.Width - 1)); + int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Y) * (xyBox.Height - 1)); + using var dot = new SolidBrush(Color.FromArgb(150, 230, 150)); + g.FillEllipse(dot, px - 3, py - 3, 7, 7); + } } public static PanelButton? HitTest(IReadOnlyList buttons, Point p) diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs index d5ab933..b9b0344 100644 --- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs +++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs @@ -1,4 +1,5 @@ using System.Text; +using RioJoy.Core.Calibration; using RioJoy.Core.Editing; using RioJoy.Core.Output; using RioJoy.Core.Mapping; @@ -465,6 +466,10 @@ public sealed class ProfileEditorForm : Form public void ShowLiveActivity(int address, bool pressed) => RunOnUi(() => _canvas.SetLivePressed(address, pressed)); + /// Show the live calibrated axis values on the encoder gauges (serial thread). + public void ShowAxes(AxisOutputs axes) => + RunOnUi(() => _canvas.SetAxes(axes)); + /// Show the firmware version from a RIO version reply (serial thread). public void ShowVersion(VersionInfo version) => RunOnUi(() => { _version = version; RenderStatus(); }); diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index c018aad..9eae12f 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -239,6 +239,7 @@ internal sealed class TrayApplicationContext : ApplicationContext { activity = editor.ShowLiveActivity; runtime.ButtonActivity += activity; + runtime.AxesUpdated += editor.ShowAxes; runtime.VersionReceived += editor.ShowVersion; runtime.CheckReceived += editor.AddCheckStatus; } @@ -248,6 +249,7 @@ internal sealed class TrayApplicationContext : ApplicationContext if (runtime is not null && activity is not null) { runtime.ButtonActivity -= activity; + runtime.AxesUpdated -= editor.ShowAxes; runtime.VersionReceived -= editor.ShowVersion; runtime.CheckReceived -= editor.AddCheckStatus; } diff --git a/tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs b/tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs new file mode 100644 index 0000000..4fb8c19 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Editing/AxisGaugesTests.cs @@ -0,0 +1,32 @@ +using RioJoy.Core.Calibration; +using RioJoy.Core.Editing; +using Xunit; + +namespace RioJoy.Core.Tests.Editing; + +public class AxisGaugesTests +{ + [Theory] + [InlineData(0, 0.0)] + [InlineData(AxisOutputs.Max, 1.0)] + [InlineData(-500, 0.0)] // clamps below range + [InlineData(AxisOutputs.Max + 500, 1.0)] // clamps above range + public void Fraction_MapsAndClamps(int value, double expected) => + Assert.Equal(expected, AxisGauges.Fraction(value), precision: 6); + + [Fact] + public void Fraction_Center_IsHalf() => + Assert.Equal(0.5, AxisGauges.Fraction(AxisOutputs.Center), precision: 3); + + [Theory] + [InlineData(AxisOutputs.Center, 0.0)] + [InlineData(0, -1.0)] + [InlineData(-500, -1.0)] // clamps below range + [InlineData(AxisOutputs.Max + 500, 1.0)] // clamps above range + public void Deflection_MapsAndClamps(int value, double expected) => + Assert.Equal(expected, AxisGauges.Deflection(value), precision: 6); + + [Fact] + public void Deflection_Max_IsFullRight() => + Assert.Equal(1.0, AxisGauges.Deflection(AxisOutputs.Max), precision: 3); +} diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs index 4bdafba..9505f56 100644 --- a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs +++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs; using RioJoy.Core; using RioJoy.Core.Mapping; using RioJoy.Core.Protocol; @@ -72,6 +73,32 @@ public class RioRuntimeTests await run; } + [Fact] + public async Task AnalogReply_RaisesAxesUpdated() + { + var fake = new FakeTransport(); + var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); + var recorder = new RecordingSink(); + + using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); + AxisOutputs? seen = null; + runtime.AxesUpdated += axes => seen = axes; + runtime.Start(); + + using var cts = new CancellationTokenSource(); + Task run = link.RunAsync(cts.Token); + + // All raw axes at zero → joystick X/Y centered, same values the sink receives. + fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10])); + + await WaitUntilAsync(() => seen is not null); + Assert.Equal(AxisOutputs.Center, seen!.Value.X); + Assert.Equal(AxisOutputs.Center, seen.Value.Y); + + cts.Cancel(); + await run; + } + [Fact] public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart() {