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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
|
||||
namespace RioJoy.Core.Editing;
|
||||
|
||||
/// <summary>
|
||||
/// Pure math for the editor's live encoder gauges: converts a calibrated axis
|
||||
/// value (<c>0..32766</c>, 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.
|
||||
/// </summary>
|
||||
public static class AxisGauges
|
||||
{
|
||||
/// <summary>Fill fraction <c>0..1</c> of an axis value (0 = empty, Max = full).</summary>
|
||||
public static double Fraction(int value) =>
|
||||
Math.Min(1.0, Math.Max(0.0, value / (double)AxisOutputs.Max));
|
||||
|
||||
/// <summary>Signed deflection <c>-1..+1</c> from the axis center (0 = centered).</summary>
|
||||
public static double Deflection(int value) =>
|
||||
Math.Min(1.0, Math.Max(-1.0, (value - AxisOutputs.Center) / (double)AxisOutputs.Center));
|
||||
}
|
||||
@@ -56,6 +56,13 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
||||
/// </summary>
|
||||
public event Action<int, bool>? ButtonActivity;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event Action<AxisOutputs>? AxesUpdated;
|
||||
|
||||
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
|
||||
public event Action<VersionInfo>? 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)
|
||||
|
||||
@@ -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<JoyStickSetting, bool>? CalibrationProvider { get; set; }
|
||||
|
||||
private readonly HashSet<int> _livePressed = new();
|
||||
private AxisOutputs? _axes;
|
||||
|
||||
public int? SelectedAddress { get; private set; }
|
||||
|
||||
@@ -43,6 +45,20 @@ internal sealed class PanelCanvas : Control
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Raised when a <c>[JoyStick]</c> toggle cell is clicked.</summary>
|
||||
public event Action<JoyStickSetting>? 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);
|
||||
}
|
||||
|
||||
@@ -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<int, string?> function,
|
||||
Func<int, bool> lit,
|
||||
Func<int, bool> 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<PanelButton> buttons, Point p)
|
||||
|
||||
@@ -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));
|
||||
|
||||
/// <summary>Show the live calibrated axis values on the encoder gauges (serial thread).</summary>
|
||||
public void ShowAxes(AxisOutputs axes) =>
|
||||
RunOnUi(() => _canvas.SetAxes(axes));
|
||||
|
||||
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
|
||||
public void ShowVersion(VersionInfo version) =>
|
||||
RunOnUi(() => { _version = version; RenderStatus(); });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user