diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs index ca68408..5b7cfe8 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs @@ -14,6 +14,15 @@ public partial class DasherCanvas private long _lastGazeTicks; private static readonly TimeSpan GazeTimeout = TimeSpan.FromMilliseconds(500); + /// True while the engine is parked on the crosshair because + /// gaze was lost; the next valid sample clears it. + private volatile bool _gazePausedForLoss; + + /// Serialises GazeFilter use between the gaze callback + /// thread (Process) and the UI thread (Reset in PauseForGazeLoss, + /// ConfigureGazeFilter swaps). + private readonly object _gazeFilterLock = new(); + public bool IsEyeTracking => _useEyeGazeInput; public bool IsEyeTrackActive => _eyeTrackActive; @@ -43,11 +52,31 @@ public async Task InitializeEyeGazeAsync(EyeGazeIntegration.TrackerType tr { _useEyeGazeInput = true; _eyeGazeIntegration.GazePositionChanged += OnEyeGazePositionChanged; + _eyeGazeIntegration.Warning += OnEyeTrackerWarning; + _gazePausedForLoss = false; + _lastGazeTicks = 0; + _crosshairCached = false; + _crosshairQueryFailedLogged = false; + // Cache the crosshair now (not first-Render) so the + // hold-before-first-paint window is empty. + CacheCrosshairScreenPosition(); EyeGazeLogger.Log("Eye gaze input active — indicator should appear (green/red dot top-right)"); } return ok; } + /// Tracker-side degradation notice (thread-pool thread): + /// log + one toast per session, marshalled to the UI thread. + private bool _gazeWarned; + private void OnEyeTrackerWarning(string message) + { + EyeGazeLogger.Log($"Tracker warning: {message}"); + if (_gazeWarned) return; + _gazeWarned = true; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + Services.ToastNotifier.Show("Eye tracker notice", message, isWarning: true)); + } + public void DisableEyeGaze() { DisableEyeGaze(blocking: true); @@ -62,9 +91,12 @@ private void DisableEyeGaze(bool blocking) { _useEyeGazeInput = false; _eyeTrackActive = false; + _gazePausedForLoss = false; + _gazeWarned = false; if (_eyeGazeIntegration != null) { _eyeGazeIntegration.GazePositionChanged -= OnEyeGazePositionChanged; + _eyeGazeIntegration.Warning -= OnEyeTrackerWarning; _eyeGazeIntegration.Shutdown(blocking); _eyeGazeIntegration = null; } @@ -75,12 +107,52 @@ private void DisableEyeGaze(bool blocking) private float _cachedOriginX; private float _cachedOriginY; + private GazeFilter? _gazeFilter; + private int _gazeLogInterval; // log every Nth sample (throttles the log) + + /// + /// Configure the gaze filter (dead zone, velocity gate, settle window). + /// Null disables all filtering (raw passthrough — pre-Juan behaviour). + /// + public void ConfigureGazeFilter(double? deadZonePx = null, double? maxVelocity = null, double? settleMs = null) + { + lock (_gazeFilterLock) + { + if (deadZonePx == null && maxVelocity == null && settleMs == null) + { + _gazeFilter = null; // disable + EyeGazeLogger.Log("Gaze filter DISABLED (raw passthrough)"); + return; + } + + _gazeFilter ??= new GazeFilter(); + if (deadZonePx.HasValue) _gazeFilter.DeadZonePx = deadZonePx.Value; + if (maxVelocity.HasValue) _gazeFilter.MaxVelocityPxPerMs = maxVelocity.Value; + if (settleMs.HasValue) _gazeFilter.SettleMs = settleMs.Value; + EyeGazeLogger.Log($"Gaze filter: deadZone={_gazeFilter.DeadZonePx}px vel={_gazeFilter.MaxVelocityPxPerMs}px/ms settle={_gazeFilter.SettleMs}ms"); + } + } + private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) { if (!_useEyeGazeInput || _handle == IntPtr.Zero) return; + if (!gazePoint.IsValid) + { + // Tracker says eyes not found: hold immediately. Dropping + // invalid samples silently left the last gaze position + // driving the zoom (the eyegaze.log field session). + PauseForGazeLoss("invalid sample"); + return; + } + _lastGazeTicks = DateTimeOffset.UtcNow.Ticks; _eyeTrackActive = true; + if (_gazePausedForLoss) + { + _gazePausedForLoss = false; + EyeGazeLogger.Log("Gaze resumed — zoom continues"); + } float x = gazePoint.X; float y = gazePoint.Y; @@ -91,9 +163,148 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) y = (float)(gazePoint.Y - _cachedOriginY); } + // Apply the gaze filter (velocity gate → dead zone → settle). + // Local capture: ConfigureGazeFilter runs on the UI thread and + // can swap the field to null between check and call. + var filter = _gazeFilter; + if (filter != null) + { + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + double filteredX = x, filteredY = y; + bool send; + lock (_gazeFilterLock) send = filter.Process(x, y, nowMs, out filteredX, out filteredY); + if (!send) + return; // filtered out (in transit, jitter, or not settled) + + // Diagnostic: log every 30th sample (≈once per 500ms at 60Hz) + if (EyeGazeLogger.Verbose) + { + _gazeLogInterval++; + if (_gazeLogInterval >= 30) + { + _gazeLogInterval = 0; + EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={filter.LastVelocity:F1}px/ms " + + $"[total={filter.TotalSamples} sent={filter.SentSamples} dz={filter.DeadZoneBlocks} vel_blk={filter.VelocityBlocks} settle_blk={filter.SettleBlocks}]"); + } + } + + x = (float)filteredX; + y = (float)filteredY; + } + NativeBridge.dasher_mouse_move(_handle, x, y); } + /// Screen X of the crosshair in engine screen space — the + /// zero-velocity point. Cached by Render (UI thread); the hold reads + /// it from the gaze callback thread. + private volatile float _cachedCrosshairScreenX; + + /// + /// Screen Y of the crosshair. BOTH axes matter: parking on the X + /// alone freezes the zoom but keeps steering the vertical pan (the + /// engine pans toward the pointer's Y at a constant rate — the + /// root-independent screen→Dasher mapping never self-corrects). The + /// full (X, Y) park is a TRUE freeze: dasher (2048, 2048) yields + /// zero zoom AND zero pan. + /// + /// Geometry assumption: valid for the default/squish geometries + /// (integer-exact screen↔Dasher round-trip). NOT maintained for + /// LP_GEOMETRY=square_no_xhair (crosshair offscreen + reverse + /// boost): the hold would reverse-zoom. Gaze users don't reach that + /// advanced engine setting via the Dasher UI. + /// + private volatile float _cachedCrosshairScreenY; + private volatile bool _crosshairCached; + private bool _crosshairQueryFailedLogged; + + /// + /// Query the engine for the crosshair's screen position. UI thread + /// (Render) or once at gaze init; retried per frame until it + /// succeeds so the uncached window is empty. + /// + private void CacheCrosshairScreenPosition() + { + try + { + var vp = new NativeBridge.DasherViewport + { + struct_size = System.Runtime.InteropServices.Marshal.SizeOf() + }; + if (NativeBridge.dasher_get_viewport(_handle, ref vp) == 0 && + NativeBridge.dasher_dasher_to_screen(_handle, vp.crosshair_x, vp.crosshair_y, out var chX, out var chY) == 0) + { + _cachedCrosshairScreenX = chX; + _cachedCrosshairScreenY = chY; + _crosshairCached = true; + } + else if (!_crosshairQueryFailedLogged) + { + _crosshairQueryFailedLogged = true; + EyeGazeLogger.Log("Gaze hold: viewport query failed — will retry each frame (park unavailable until it succeeds)"); + } + } + catch (Exception ex) + { + if (!_crosshairQueryFailedLogged) + { + _crosshairQueryFailedLogged = true; + EyeGazeLogger.Log($"Gaze hold: viewport query threw: {ex.Message} — will retry each frame"); + } + } + } + + /// + /// Park the pointer on the crosshair: the crosshair is the engine's + /// zero-velocity point, so the zoom AND pan freeze WITHOUT stopping + /// the engine — no resume click is needed and the next valid sample + /// simply moves again. (The stop-outside idiom was rejected in + /// review: BP_STOP_OUTSIDE performs a full engine stop that needs a + /// Primary_Input click to resume — a click gaze users can't make; + /// every blink would have halted typing.) + /// + private void PauseForGazeLoss(string reason) + { + _eyeTrackActive = false; + var filter = _gazeFilter; + lock (_gazeFilterLock) filter?.Reset(); + if (_gazePausedForLoss) return; // already holding + _gazePausedForLoss = true; + if (!_crosshairCached) + { + // Not cached yet (gaze lost before the first Render): retry + // now. If it still fails, skip the park this instant — the + // next Render caches and subsequent losses hold properly. + CacheCrosshairScreenPosition(); + if (!_crosshairCached) + { + EyeGazeLogger.Log($"Gaze lost ({reason}) — crosshair not cached yet, holding skipped this once"); + return; + } + } + EyeGazeLogger.Log($"Gaze lost ({reason}) — holding on crosshair"); + try + { + NativeBridge.dasher_mouse_move(_handle, _cachedCrosshairScreenX, _cachedCrosshairScreenY); + } + catch (Exception ex) + { + EyeGazeLogger.Log($"PauseForGazeLoss: mouse_move failed: {ex.Message}"); + } + } + + /// Staleness backstop: called from Render (UI thread, RAF + /// cadence) — if no valid sample arrived within GazeTimeout (e.g. + /// the tracker stopped delivering entirely), pause. The indicator's + /// red dot rides the same check. + private void CheckGazeStaleness() + { + if (!_useEyeGazeInput) return; + if (_lastGazeTicks == 0 || DateTimeOffset.UtcNow - new DateTimeOffset(_lastGazeTicks, TimeSpan.Zero) <= GazeTimeout) + return; + PauseForGazeLoss("no gaze data for " + GazeTimeout.TotalMilliseconds + "ms"); + } + private void DrawEyeTrackIndicator(DrawingContext context) { if (!_useEyeGazeInput) return; @@ -111,7 +322,8 @@ private void DrawEyeTrackIndicator(DrawingContext context) var color = _eyeTrackActive ? Color.FromRgb(80, 200, 80) // green - : Color.FromRgb(200, 60, 60); // red + : Color.FromRgb(200, 60, 60) // red + ; context.DrawEllipse( new SolidColorBrush(color), diff --git a/src/Dasher.Windows/Controls/DasherCanvas.cs b/src/Dasher.Windows/Controls/DasherCanvas.cs index ed9368d..86f0cfa 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.cs @@ -233,6 +233,18 @@ public override void Render(DrawingContext context) var scaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1.0; _cachedOriginX = (float)(origin.X / scaling); _cachedOriginY = (float)(origin.Y / scaling); + + // Crosshair screen position — the zero-velocity point used to + // hold the zoom when gaze is lost (PauseForGazeLoss). Zoom- + // invariant but RESIZE-dependent: TrySetScreenSize invalidates + // the cache when the canvas dims change; until then the cached + // point is reused (2 P/Invokes saved per frame). + if (!_crosshairCached) + CacheCrosshairScreenPosition(); + + // Staleness backstop: catches a tracker that stops delivering + // entirely (no invalid samples either). + CheckGazeStaleness(); } DrawEyeTrackIndicator(context); @@ -383,6 +395,11 @@ private void TrySetScreenSize() NativeBridge.dasher_set_screen_size(_handle, w, h); _lastScreenWidth = w; _lastScreenHeight = h; + // The crosshair's screen position (the gaze hold's zero-velocity + // point) is resize-dependent — invalidate so the next Render + // recaches it (review loop 3: a stale park silently reintroduced + // the pan-during-blink bug after any resize/DPI change). + _crosshairCached = false; } private void Tick(long timeMs) diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index 98fc9a7..7465f37 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -623,6 +623,130 @@ void PopulateSelections(AccessMethod method) Margin = new Thickness(0, 2, 0, 0), }; + // ── Gaze filter controls (transit zoom fix; tune for eye tracker latency) ── + var filterHeader = new TextBlock + { + Text = "Gaze Smoothing", + FontSize = 12, + FontWeight = FontWeight.Medium, + Foreground = BrushLabel, + Margin = new Thickness(0, 10, 0, 0), + }; + + var filterToggle = new ToggleSwitch + { + IsChecked = config.GazeFilterEnabled, + OnContent = "", OffContent = "", MinWidth = 0, Height = 24, + }; + var filterToggleLabel = new TextBlock + { + Text = "Filter gaze (reduces zoom toward wrong positions during eye transit)", + FontSize = 12, + Foreground = BrushValue, + VerticalAlignment = VerticalAlignment.Center, + }; + var filterTogglePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + filterTogglePanel.Children.Add(filterToggle); + filterTogglePanel.Children.Add(filterToggleLabel); + + var gazeFilterHelp = new TextBlock + { + Text = "Reduces zoom toward wrong positions while your eyes move to a target", + FontSize = 11, + Foreground = BrushMuted, + TextWrapping = TextWrapping.Wrap, + }; + + // Eye-tracker diagnostics toggle: detailed per-sample logging to + // %APPDATA%\Dasher\eyegaze.log (off by default; lifecycle events + // always logged). The file to attach to bug reports. + var diagToggle = new ToggleSwitch + { + IsChecked = config.GazeDiagnostics, + OnContent = "", OffContent = "", MinWidth = 0, Height = 24, + }; + var diagToggleLabel = new TextBlock + { + Text = "Save eye-tracker diagnostics", + FontSize = 12, + Foreground = BrushValue, + VerticalAlignment = VerticalAlignment.Center, + }; + var diagTogglePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + diagTogglePanel.Children.Add(diagToggle); + diagTogglePanel.Children.Add(diagToggleLabel); + var diagHelp = new TextBlock + { + Text = "Detailed tracking log for bug reports (per-sample lines, written to eyegaze.log in the Dasher folder). Basic connection events are always logged.", + FontSize = 11, + Foreground = BrushMuted, + TextWrapping = TextWrapping.Wrap, + }; + diagToggle.PropertyChanged += (s, e) => + { + if (e.Property.Name != "IsChecked") return; + var cfg = AccessConfiguration.Load(); + cfg.GazeDiagnostics = diagToggle.IsChecked == true; + cfg.Save(); + EyeGaze.EyeGazeLogger.Verbose = cfg.GazeDiagnostics; + }; + + var deadZoneSlider = new Slider + { + Minimum = 0, Maximum = 30, Value = config.GazeDeadZonePx, + Width = 180, FontSize = 12, + }; + var deadZoneLabel = new TextBlock { Text = $"Dead zone: {config.GazeDeadZonePx:F0} px", FontSize = 11, Foreground = BrushValue }; + deadZoneSlider.PropertyChanged += (s, e) => + { + if (e.Property.Name == "Value") + deadZoneLabel.Text = $"Dead zone: {deadZoneSlider.Value:F0} px"; + }; + + var settleSlider = new Slider + { + Minimum = 0, Maximum = 300, Value = config.GazeSettleMs, + Width = 180, FontSize = 12, + }; + var settleLabel = new TextBlock { Text = $"Settle time: {config.GazeSettleMs:F0} ms", FontSize = 11, Foreground = BrushValue }; + settleSlider.PropertyChanged += (s, e) => + { + if (e.Property.Name == "Value") + settleLabel.Text = $"Settle time: {settleSlider.Value:F0} ms"; + }; + + void SaveFilterConfig() + { + var cfg = AccessConfiguration.Load(); + cfg.GazeFilterEnabled = filterToggle.IsChecked ?? true; + cfg.GazeDeadZonePx = deadZoneSlider.Value; + cfg.GazeSettleMs = settleSlider.Value; + cfg.Save(); + // Apply live if canvas is available + var canvas = this.FindControl("DasherCanvas"); + if (canvas != null) + { + if (cfg.GazeFilterEnabled) + canvas.ConfigureGazeFilter(cfg.GazeDeadZonePx, null, cfg.GazeSettleMs); + else + canvas.ConfigureGazeFilter(); // disable + } + } + + // Debounced save on slider change + Avalonia.Threading.DispatcherTimer? filterSaveTimer = null; + void ScheduleFilterSave() + { + filterSaveTimer?.Stop(); + filterSaveTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + filterSaveTimer.Tick += (_, _) => { filterSaveTimer!.Stop(); SaveFilterConfig(); }; + filterSaveTimer.Start(); + } + + deadZoneSlider.PropertyChanged += (s, e) => { if (e.Property.Name == "Value") ScheduleFilterSave(); }; + settleSlider.PropertyChanged += (s, e) => { if (e.Property.Name == "Value") ScheduleFilterSave(); }; + filterToggle.PropertyChanged += (s, e) => { if (e.Property.Name == "IsChecked") ScheduleFilterSave(); }; + void UpdateTrackerVisibility() { var isEyeGaze = methods[methodCombo.SelectedIndex] == AccessMethod.EyeGaze; @@ -631,6 +755,15 @@ void UpdateTrackerVisibility() var isUdp = isEyeGaze && trackerCombo.SelectedIndex == 1; portPanel.IsVisible = isUdp; trackerHelp.IsVisible = false; + filterHeader.IsVisible = isEyeGaze; + filterTogglePanel.IsVisible = isEyeGaze; + gazeFilterHelp.IsVisible = isEyeGaze; + diagTogglePanel.IsVisible = isEyeGaze; + diagHelp.IsVisible = isEyeGaze; + deadZoneSlider.IsVisible = isEyeGaze; + deadZoneLabel.IsVisible = isEyeGaze; + settleSlider.IsVisible = isEyeGaze; + settleLabel.IsVisible = isEyeGaze; if (!isEyeGaze) return; @@ -806,6 +939,15 @@ void UpdateTrackerVisibility() panel.Children.Add(trackerCombo); panel.Children.Add(portPanel); panel.Children.Add(trackerHelp); + panel.Children.Add(filterHeader); + panel.Children.Add(filterTogglePanel); + panel.Children.Add(gazeFilterHelp); + panel.Children.Add(diagTogglePanel); + panel.Children.Add(diagHelp); + panel.Children.Add(deadZoneSlider); + panel.Children.Add(deadZoneLabel); + panel.Children.Add(settleSlider); + panel.Children.Add(settleLabel); panel.Children.Add(filterLabel); panel.Children.Add(filterCombo); panel.Children.Add(filterHelp); diff --git a/src/Dasher.Windows/Engine/AccessConfiguration.cs b/src/Dasher.Windows/Engine/AccessConfiguration.cs index 7816d3e..15b0ff0 100644 --- a/src/Dasher.Windows/Engine/AccessConfiguration.cs +++ b/src/Dasher.Windows/Engine/AccessConfiguration.cs @@ -11,6 +11,29 @@ public class AccessConfiguration public string EyeTrackerType { get; set; } = "WindowsNative"; public int UdpPort { get; set; } = 5555; + // ── Gaze filter (transit zoom fix; Juan's Tobii PCEye + TD Control) ──── + + /// Minimum px movement before sending (0 = always send). + public double GazeDeadZonePx { get; set; } = 8; + + /// Maximum velocity px/ms before holding (0 = disabled). + public double GazeMaxVelocityPxPerMs { get; set; } = 3.0; + + /// Ms the gaze must dwell before sending (0 = immediate). + public double GazeSettleMs { get; set; } = 80; + + /// Master switch: false = raw passthrough (pre-filter behaviour). + public bool GazeFilterEnabled { get; set; } = true; + + /// + /// Detailed eye-tracker diagnostics (per-sample flow lines, raw struct + /// dumps, filter stats) written to %APPDATA%\Dasher\eyegaze.log. The + /// always-on layer is lifecycle events only (connect, resume, heartbeat + /// counters, gaze lost/resumed, warnings). Default off; turn on when + /// reporting tracker problems. + /// + public bool GazeDiagnostics { get; set; } + /// /// The engine input filter (SP_INPUT_FILTER). MIRROR ONLY — the engine's /// own persistence (dasher_settings.xml, DasherCore #76 / v0.2.16+) owns diff --git a/src/Dasher.Windows/Engine/EyeGazeIntegration.cs b/src/Dasher.Windows/Engine/EyeGazeIntegration.cs index 39cbae1..ac790cd 100644 --- a/src/Dasher.Windows/Engine/EyeGazeIntegration.cs +++ b/src/Dasher.Windows/Engine/EyeGazeIntegration.cs @@ -26,6 +26,11 @@ public class Settings } public event EventHandler? GazePositionChanged; + + /// Forwarded tracker warnings (degraded mode, permissions) + /// — raised on the tracker's thread (see IEyeTrackerService.Warning). + public event Action? Warning; + public bool IsActive => _device?.IsEnabled ?? false; public async Task InitializeAsync(Settings settings) @@ -41,6 +46,8 @@ public async Task InitializeAsync(Settings settings) return false; } + tracker.Warning += w => Warning?.Invoke(w); + _device = new EyeGazeInputDevice(); var ok = await _device.InitializeAsync(tracker); if (ok) diff --git a/src/Dasher.Windows/Engine/NativeBridge.cs b/src/Dasher.Windows/Engine/NativeBridge.cs index 64e6620..cc8da0d 100644 --- a/src/Dasher.Windows/Engine/NativeBridge.cs +++ b/src/Dasher.Windows/Engine/NativeBridge.cs @@ -231,6 +231,32 @@ public static extern void dasher_frame(IntPtr ctx, long time_ms, public static extern int dasher_import_training_text(IntPtr ctx, [MarshalAs(UnmanagedType.LPUTF8Str)] string text); + // ── Viewport / coordinate conversion (engine screen space) ──────────── + // Used by the gaze-loss hold: the crosshair is the zero-velocity point, + // so parking the pointer on its screen position freezes the zoom WITHOUT + // stopping the engine (no resume click needed — the next valid sample + // simply moves again). NOTE: dasher.h marks these as testing-oriented + // APIs that may change between engine versions — the pinned submodule + + // struct_size versioning keep that safe here. + + [StructLayout(LayoutKind.Sequential)] + public struct DasherViewport + { + public int struct_size; + public long crosshair_x; + public long crosshair_y; + public long visible_min_y; + public long visible_max_y; + public int screen_width; + public int screen_height; + } + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int dasher_get_viewport(IntPtr ctx, ref DasherViewport viewport); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int dasher_dasher_to_screen(IntPtr ctx, long dx, long dy, out int sx, out int sy); + // Absolute path of the current alphabet's user training file — the file // adaptive learning appends to and the UI reads/exports/resets // (DasherCore#84 / #53). Pointer valid until the next engine call. diff --git a/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs b/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs index 8ebed10..e8f4e78 100644 --- a/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs +++ b/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs @@ -11,6 +11,16 @@ public static class EyeGazeLogger Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Dasher", "eyegaze.log"); + /// + /// Detailed diagnostics (per-sample flow lines, heartbeat detail, raw + /// struct dumps, gaze filter stats). Off by default — the always-on + /// layer is lifecycle events only (connect, resume, warnings, gaze + /// lost/resumed), which is low-volume and exactly what field triage + /// needs first. Users reporting tracker problems turn this on in + /// Settings > Input ("Save eye-tracker diagnostics"). + /// + public static bool Verbose; + private static long _gazeLogCounter; private const long GazeLogInterval = 60; // log every 60th sample (~2s at 33Hz) @@ -40,6 +50,7 @@ public static void Log(string message) public static void LogGazeData(float x, float y, bool valid) { + if (!Verbose) return; var count = Interlocked.Increment(ref _gazeLogCounter); if (count == 1) Log($"First gaze data received: x={x:F1} y={y:F1} valid={valid}"); diff --git a/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs b/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs index ab1f4a7..0f5acb0 100644 --- a/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs +++ b/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs @@ -16,6 +16,7 @@ public sealed class EyetuitiveTracker : IEyeTrackerService private bool _disposed; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "eyetuitive"; public bool IsConnected { get; private set; } diff --git a/src/Dasher.Windows/EyeGaze/GazeFilter.cs b/src/Dasher.Windows/EyeGaze/GazeFilter.cs new file mode 100644 index 0000000..1a7e392 --- /dev/null +++ b/src/Dasher.Windows/EyeGaze/GazeFilter.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; + +namespace Dasher.Windows.EyeGaze; + +/// +/// Filters raw gaze data before it reaches the engine's zoom. Solves the +/// "transit zoom" problem reported by eye-tracker users (Juan, Argentina, +/// Tobii PCEye + TD Control): when the user looks at a letter, the tracker +/// takes milliseconds to move its cursor there, and every intermediate +/// position was sent to dasher_mouse_move — zooming the canvas toward the +/// wrong places during transit. +/// +/// Three filters, in order: +/// +/// 1. VELOCITY GATE: if the gaze is moving faster than N px/ms, it's in +/// transit (saccade) — hold the last sent position until velocity drops. +/// +/// 2. DEAD ZONE: if the gaze hasn't moved more than N screen px from the +/// last SENT position, don't send — reduces jitter noise. +/// +/// 3. SETTLE WINDOW: the gaze must stay near the candidate for N ms +/// before the position is sent — ensures the user has ARRIVED at their +/// target, not just passing through. +/// +/// All thresholds configurable for experimentation; 0 disables each filter. +/// +public sealed class GazeFilter +{ + // ── Configuration (all tunable; 0 = disabled) ───────────────────────── + + /// Minimum px movement before sending (0 = always send). Default 8. + public double DeadZonePx { get; set; } = 8; + + /// Maximum velocity px/ms before positions are held (0 = disabled). Default 3.0. + public double MaxVelocityPxPerMs { get; set; } = 3.0; + + /// Ms the gaze must dwell before sending (0 = send immediately). Default 80. + public double SettleMs { get; set; } = 80; + + // ── State ─────────────────────────────────────────────────────────────── + + private double _lastSentX, _lastSentY; + private double _candidateX, _candidateY; + private long _candidateStartMs; + private bool _hasCandidate; + private long _lastSampleMs; + private double _lastSampleX, _lastSampleY; + private double _smoothedVelocity; + private bool _hasSent; + + // Diagnostics (for the eyegaze log) + public int TotalSamples { get; private set; } + public int SentSamples { get; private set; } + public int DeadZoneBlocks { get; private set; } + public int VelocityBlocks { get; private set; } + public int SettleBlocks { get; private set; } + public double LastVelocity { get; private set; } + + /// + /// Process a raw gaze sample. Returns true when the position should be + /// sent to the engine (passed all filters), with the filtered position + /// in the out parameters. + /// + public bool Process(double x, double y, long timestampMs, out double outX, out double outY) + { + outX = x; outY = y; + TotalSamples++; + + // Compute instantaneous velocity (px per ms). The FIRST sample after + // construction/Reset must not be measured against the origin (0,0) — + // that phantom "saccade" (e.g. 8.8 px/ms for a first gaze at + // (100,100)) blocked the first real positions after every gaze-loss + // recovery; found by GazeFilterTests while pinning the semantics. + double velocity = 0; + if (_lastSampleMs != 0) + { + var dtMs = Math.Max(1, timestampMs - _lastSampleMs); + var dx = x - _lastSampleX; + var dy = y - _lastSampleY; + var distance = Math.Sqrt(dx * dx + dy * dy); + velocity = distance / dtMs; + // Exponential smoothing (alpha=0.3) — single-sample spikes don't + // trigger the gate; sustained transit does + _smoothedVelocity = _smoothedVelocity * 0.7 + velocity * 0.3; + } + else + { + _smoothedVelocity = 0; + } + LastVelocity = _smoothedVelocity; + _lastSampleMs = timestampMs; + _lastSampleX = x; + _lastSampleY = y; + + // FILTER 1: velocity gate — in transit, hold position + if (MaxVelocityPxPerMs > 0 && _smoothedVelocity > MaxVelocityPxPerMs) + { + VelocityBlocks++; + _hasCandidate = false; // cancel any pending settle + return false; + } + + // FILTER 2: dead zone — too close to last sent position + if (_hasSent && DeadZonePx > 0) + { + var sentDx = x - _lastSentX; + var sentDy = y - _lastSentY; + if (Math.Sqrt(sentDx * sentDx + sentDy * sentDy) < DeadZonePx) + { + DeadZoneBlocks++; + _hasCandidate = false; // within dead zone — reset settle + return false; + } + } + + // FILTER 3: settle window — must dwell for N ms + if (SettleMs <= 0) + { + // No settle required — send immediately + Send(x, y, ref outX, ref outY); + return true; + } + + if (!_hasCandidate) + { + // New candidate position — start the settle timer. The tolerance + // is its own (NOT the dead zone): a zero dead zone with settle + // on must still allow sub-pixel jitter to complete the dwell — + // reusing DeadZonePx there meant 1px noise restarted the timer + // forever and NOTHING was ever sent (review finding). + _candidateX = x; + _candidateY = y; + _candidateStartMs = timestampMs; + _hasCandidate = true; + SettleBlocks++; + return false; + } + + var settleTol = DeadZonePx > 0 ? DeadZonePx : 2.0; + if (Math.Abs(x - _candidateX) > settleTol || Math.Abs(y - _candidateY) > settleTol) + { + _candidateX = x; + _candidateY = y; + _candidateStartMs = timestampMs; + SettleBlocks++; + return false; + } + + // Same candidate — check if settled long enough + if (timestampMs - _candidateStartMs >= SettleMs) + { + Send(_candidateX, _candidateY, ref outX, ref outY); + _hasCandidate = false; + return true; + } + + SettleBlocks++; + return false; + } + + private void Send(double x, double y, ref double outX, ref double outY) + { + _lastSentX = x; + _lastSentY = y; + _hasSent = true; + SentSamples++; + outX = x; + outY = y; + } + + /// Reset all state (mode switch, re-calibration). + public void Reset() + { + _hasCandidate = false; + _hasSent = false; + _lastSampleMs = 0; + _smoothedVelocity = 0; + TotalSamples = SentSamples = DeadZoneBlocks = VelocityBlocks = SettleBlocks = 0; + } +} diff --git a/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs b/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs index 23c419a..a9743d8 100644 --- a/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs +++ b/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs @@ -6,6 +6,14 @@ namespace Dasher.Windows.EyeGaze public interface IEyeTrackerService : IDisposable { event EventHandler GazeDataReceived; + + /// + /// Non-fatal tracker condition the user should know about (degraded + /// mode, permissions, licensing) — surfaces as a toast + log line. + /// Raised from the tracker's connect/callback thread. + /// + event Action? Warning; + string TrackerName { get; } bool IsConnected { get; } Task ConnectAsync(); diff --git a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs index f647ee3..6e8a5cb 100644 --- a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs +++ b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs @@ -106,6 +106,27 @@ private static string GetTobiiError(int code) private bool _disposed; public event EventHandler? GazeDataReceived; + public event Action? Warning; + + /// + /// Next-log-round diagnostics (the "run as administrator" experiment): + /// resume_device needs elevation on OEM IS5 sensors — logging whether + /// this process IS elevated makes the next eyegaze.log self-interpreting. + /// + private static void LogElevationStatus() + { + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + var elevated = principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + EyeGazeLogger.Log($"Tobii: process elevation = {elevated}"); + } + catch (Exception ex) + { + EyeGazeLogger.Log($"Tobii: elevation check failed: {ex.Message}"); + } + } public string TrackerName => "Tobii (Stream Engine)"; public bool IsConnected { get; private set; } @@ -126,11 +147,12 @@ private static void DeviceUrlCallback(string url, IntPtr user_data) public Task ConnectAsync() { - return Task.Run(() => + return Task.Run(async () => { try { EyeGazeLogger.Log("Tobii: ConnectAsync starting"); + LogElevationStatus(); EnsureDllLoaded(); EyeGazeLogger.Log("Tobii: DLL resolved"); @@ -209,9 +231,30 @@ public Task ConnectAsync() int ssResult = tobii_stream_supported(_device, 0, out bool gazeSupported); EyeGazeLogger.Log($"Tobii: Gaze point stream supported = {gazeSupported} (result code {ssResult})"); - // Resume device in case it's paused + // Resume device in case it's paused. Some OEM sensors (IS5/ + // Dynavox, e.g. IS514) require elevation for this — a + // permissions failure leaves them in a degraded low-rate, + // low-accuracy mode (one field session measured a 30 Hz + // ceiling at 60% validity instead of 90 Hz). Retry once, + // then surface the degradation to the user. int rdResult = tobii_resume_device(_device); - EyeGazeLogger.Log($"Tobii: resume_device result = {rdResult}{(rdResult != 0 ? $": {GetTobiiError(rdResult)}" : "")}"); + if (rdResult != 0) + { + EyeGazeLogger.Log($"Tobii: resume_device result = {rdResult}: {GetTobiiError(rdResult)} — retrying once in 500ms"); + await Task.Delay(500); + rdResult = tobii_resume_device(_device); + } + if (rdResult != 0) + { + EyeGazeLogger.Log($"Tobii: resume_device still failing ({rdResult}: {GetTobiiError(rdResult)}) — continuing in possibly degraded mode"); + Warning?.Invoke( + $"The eye tracker could not be fully resumed ({GetTobiiError(rdResult)}). " + + "Tracking may run at reduced speed and accuracy — try running Dasher as administrator."); + } + else + { + EyeGazeLogger.Log("Tobii: resume_device ok"); + } return true; } catch (Exception ex) @@ -428,11 +471,13 @@ private void ProcessCallbacks(CancellationToken ct) continue; } - // Heartbeat every 5 seconds + // Heartbeat every 5 seconds — the valid/invalid counters are + // the field-triage signal (degradation = rate ceiling + + // validity collapse), so this rides the ALWAYS-ON layer. var now = DateTime.UtcNow; if (now - lastHeartbeat >= TimeSpan.FromSeconds(5)) { - EyeGazeLogger.Log($"Tobii: Heartbeat — {sampleCount} callbacks processed, {_validCount} valid, {_invalidCount} invalid"); + EyeGazeLogger.Log($"Tobii: Heartbeat — {sampleCount} callbacks processed, {_validCount} valid, {_invalidCount} invalid{(EyeGazeLogger.Verbose ? "" : " (diagnostics off — Settings > Input to enable per-sample lines)")}"); lastHeartbeat = now; } } @@ -455,18 +500,28 @@ private void ProcessCallbacks(CancellationToken ct) private void OnGazePoint(ref tobii_gaze_point_t gaze_point) { - // Dump raw struct bytes for the first few samples to verify layout - var dumpIdx = Interlocked.Increment(ref _rawDumpCount); - if (dumpIdx <= MaxRawDumps) + // Raw struct dumps (diagnostics only — layout verification) + if (EyeGazeLogger.Verbose) { - EyeGazeLogger.Log($"Tobii: RAW gaze #{dumpIdx}: ts={gaze_point.timestamp_us} validity={gaze_point.validity} x={gaze_point.x:F6} y={gaze_point.y:F6}"); - EyeGazeLogger.Log($" raw floats: ts_hex={BitConverter.DoubleToInt64Bits(gaze_point.timestamp_us):X16} x_bits={BitConverter.SingleToInt32Bits(gaze_point.x):X8} y_bits={BitConverter.SingleToInt32Bits(gaze_point.y):X8}"); + var dumpIdx = Interlocked.Increment(ref _rawDumpCount); + if (dumpIdx <= MaxRawDumps) + { + EyeGazeLogger.Log($"Tobii: RAW gaze #{dumpIdx}: ts={gaze_point.timestamp_us} validity={gaze_point.validity} x={gaze_point.x:F6} y={gaze_point.y:F6}"); + EyeGazeLogger.Log($" raw floats: ts_hex={BitConverter.DoubleToInt64Bits(gaze_point.timestamp_us):X16} x_bits={BitConverter.SingleToInt32Bits(gaze_point.x):X8} y_bits={BitConverter.SingleToInt32Bits(gaze_point.y):X8}"); + } } if (gaze_point.validity == 0) { Interlocked.Increment(ref _invalidCount); EyeGazeLogger.LogGazeData(gaze_point.x, gaze_point.y, valid: false); + // Forward the LOSS itself: the canvas pauses the engine on + // invalid samples (dropping them silently left the last gaze + // position driving the zoom — the eyegaze.log field session + // showed a 100-minute 0%-valid stretch). + GazeDataReceived?.Invoke(this, new GazePoint( + gaze_point.x * ScreenWidth, gaze_point.y * ScreenHeight, + DateTimeOffset.UtcNow, isValid: false, isScreenCoordinates: true)); return; } diff --git a/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs b/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs index 85fb3e8..73d54b9 100644 --- a/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs +++ b/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs @@ -15,6 +15,7 @@ public class UdpGazeTracker : IEyeTrackerService private Task? _receiveTask; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "UDP Gaze Tracker"; public bool IsConnected { get; private set; } public int UdpPort { get; set; } = 5555; diff --git a/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs b/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs index c4cb620..8163c1f 100644 --- a/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs +++ b/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs @@ -10,6 +10,7 @@ public sealed class WindowsGazeTracker : IEyeTrackerService private bool _isTracking; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "Windows Eye Tracker"; public bool IsConnected { get; private set; } @@ -57,7 +58,13 @@ private void OnGazeMoved(GazeInputSourcePreview sender, GazeMovedPreviewEventArg var point = args.CurrentPoint; var pos = point.EyeGazePosition; - if (pos == null) return; + if (pos == null) + { + // No gaze for this frame (eyes not found) — forward the loss + // so the canvas can pause the engine (see TobiiStreamEngine). + GazeDataReceived?.Invoke(this, new GazePoint(-1, -1, DateTimeOffset.UtcNow, isValid: false, isScreenCoordinates: true)); + return; + } var coords = pos.Value; GazeDataReceived?.Invoke(this, new GazePoint( diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 539722b..4d81b98 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -714,6 +714,23 @@ private async void RestoreEyeGazeFromConfig() if (_canvas == null) return; _canvas.DisableJoystick(); + // Detailed eye-tracker diagnostics (per-sample lines, raw dumps, + // filter stats). Lifecycle logging is always on. + EyeGaze.EyeGazeLogger.Verbose = config.GazeDiagnostics; + + // Apply the gaze filter configuration (transit zoom fix) + if (config.GazeFilterEnabled) + { + _canvas.ConfigureGazeFilter( + config.GazeDeadZonePx, + config.GazeMaxVelocityPxPerMs, + config.GazeSettleMs); + } + else + { + _canvas.ConfigureGazeFilter(); // disable (raw passthrough) + } + var ok = await _canvas.InitializeEyeGazeAsync(trackerType, config.UdpPort); if (!ok) { diff --git a/tests/Dasher.Windows.Tests/GazeFilterTests.cs b/tests/Dasher.Windows.Tests/GazeFilterTests.cs new file mode 100644 index 0000000..8c13f3b --- /dev/null +++ b/tests/Dasher.Windows.Tests/GazeFilterTests.cs @@ -0,0 +1,152 @@ +using Dasher.Windows.EyeGaze; + +namespace Dasher.Windows.Tests; + +/// +/// GazeFilter (dead zone + velocity gate + settle window) — the transit-zoom +/// fix from the eyegaze diagnostic work (Juan's report; validated against the +/// eyegaze.log field session). Pure class, deterministic timestamps. +/// +public class GazeFilterTests +{ + private static long T(int ms) => 1_000_000 + ms; // arbitrary epoch offset + + [Fact] + public void First_sample_with_no_settle_sent_immediately() + { + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out var x, out var y)); + Assert.Equal(100, x); + Assert.Equal(100, y); + } + + [Fact] + public void Dead_zone_blocks_jitter_around_last_sent() + { + var f = new GazeFilter { DeadZonePx = 10, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); // sent at (100,100) + Assert.False(f.Process(105, 103, T(16), out _, out _)); // 5.8px < 10px: jitter + Assert.False(f.Process(108, 104, T(32), out _, out _)); // still inside + Assert.Equal(1, f.SentSamples); + Assert.Equal(2, f.DeadZoneBlocks); + } + + [Fact] + public void Movement_beyond_dead_zone_passes() + { + var f = new GazeFilter { DeadZonePx = 10, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + Assert.True(f.Process(130, 100, T(16), out var x, out _)); // 30px jump + Assert.Equal(130, x); + } + + [Fact] + public void Velocity_gate_holds_position_during_saccade_transit() + { + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 2.0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + // 100px in 16ms = 6.25 px/ms — well over the gate. The smoothed + // velocity (0.7*0 + 0.3*6.25 = 1.9) is still under on the FIRST + // transit sample, then crosses it. + Assert.True(f.Process(200, 100, T(16), out _, out _)); // smoothing lags + Assert.False(f.Process(300, 100, T(32), out _, out _)); // gate active + Assert.False(f.Process(400, 100, T(48), out _, out _)); // still in transit + // Arrive: dwell at the target. The smoothed velocity decays + // exponentially (alpha 0.3) — needs a few stationary samples to + // cross back under the gate. + var sent = false; + for (var t = 100; t <= 300 && !sent; t += 16) + sent = f.Process(401, 100, T(t), out _, out _); + Assert.True(sent); + Assert.True(f.VelocityBlocks >= 1); + } + + [Fact] + public void Settle_window_requires_dwell_before_sending() + { + var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 0, SettleMs = 80 }; + // First sample becomes the settle CANDIDATE — nothing is sent until + // the gaze dwells on it for SettleMs. + Assert.False(f.Process(100, 100, T(0), out _, out _)); + Assert.False(f.Process(102, 101, T(40), out _, out _)); // dwelling, 40ms < 80ms + // 90ms dwell on the same candidate: sent — at the CANDIDATE + // position where the dwell began, not the latest sample. + Assert.True(f.Process(103, 101, T(90), out var x, out var y)); + Assert.Equal(100, x); + Assert.Equal(100, y); + } + + [Fact] + public void Settle_candidate_resets_when_gaze_moves_on() + { + var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 0, SettleMs = 80 }; + f.Process(100, 100, T(0), out _, out _); // candidate (100,100) + f.Process(250, 100, T(40), out _, out _); // moved way off: NEW candidate + // 60ms into the new candidate's dwell — still not settled + Assert.False(f.Process(251, 100, T(100), out _, out _)); + // ...and the original (100,100) never got sent at 90ms (it was + // cancelled when the gaze moved). + Assert.Equal(0, f.SentSamples); + } + + [Fact] + public void Reset_clears_state_for_a_fresh_session() + { + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + Assert.Equal(1, f.SentSamples); + f.Reset(); + Assert.Equal(0, f.SentSamples); + Assert.Equal(0, f.TotalSamples); + // After reset the filter behaves as new: first sample sends (no + // dead-zone reference, no velocity baseline). + Assert.True(f.Process(500, 500, T(1000), out _, out _)); + } + + [Fact] + public void First_sample_is_not_measured_against_the_origin() + { + // Regression (found while writing these tests): the first sample + // after construction/Reset computed velocity from the ORIGIN (0,0) — + // a phantom ~8.8 px/ms saccade for a first gaze at (100,100) that + // velocity-blocked the first real positions after every gaze-loss + // recovery. With the gate ON and settle off, the first sample must + // send immediately. + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 2.0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + Assert.Equal(0, f.LastVelocity, precision: 6); + // And after Reset (every gaze-loss recovery): + f.Reset(); + Assert.True(f.Process(400, 300, T(1000), out _, out _)); + } + + [Fact] + public void Zero_dead_zone_with_settle_still_completes_the_dwell() + { + // Regression (review finding): the settle candidate check reused + // DeadZonePx as its tolerance — with the dead zone set to 0 (the + // settings slider's minimum), 1px jitter restarted the dwell timer + // forever and NOTHING was ever sent. + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 50 }; + Assert.False(f.Process(100, 100, T(0), out _, out _)); // candidate + Assert.False(f.Process(101, 100, T(20), out _, out _)); // 1px jitter + Assert.False(f.Process(100, 101, T(40), out _, out _)); // more jitter + Assert.True(f.Process(101, 100, T(60), out _, out _)); // dwell completed despite jitter + } + + [Fact] + public void Velocity_gate_cancels_pending_settle() + { + // Dwelling at a candidate completes and sends; a saccade after a NEW + // candidate begins must cancel the dwell — nothing sent mid-transit. + var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 1.0, SettleMs = 50 }; + Assert.False(f.Process(100, 100, T(0), out _, out _)); // candidate starts + Assert.True(f.Process(100, 100, T(60), out _, out _)); // dwell completed: sent + Assert.Equal(1, f.SentSamples); + // New target: candidate starts, then transit begins before dwell + f.Process(400, 100, T(80), out _, out _); // fast move: gate or new candidate + var sentBefore = f.SentSamples; + Assert.False(f.Process(500, 100, T(96), out _, out _)); // transit: held + Assert.Equal(sentBefore, f.SentSamples); + } +}