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() {