From 23171985852d10f8edd8eaedbacc79120e730601 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 18:54:17 -0500
Subject: [PATCH 01/15] Add 3D lap review, map exports and retained contact
telemetry
---
docs/LAP-REVIEW-3D-PRIVATE.md | 50 +++
installer/PrivateShiftCaptureGuide.txt | 86 +++--
.../DebugLogging/HealthContextRecorder.cs | 2 +-
src/Wisp.App/Laps/LapRunCapture.cs | 1 +
src/Wisp.App/Runs/LapReviewPalette.cs | 55 +++
src/Wisp.App/Runs/LapReviewPlot.cs | 35 +-
src/Wisp.App/Runs/LapReviewTrack3D.cs | 297 +++++++++++++++
src/Wisp.App/Runs/LapReviewTrackGeometry.cs | 337 ++++++++++++++++++
src/Wisp.App/Runs/LapReviewView.xaml | 105 +++++-
src/Wisp.App/Runs/LapReviewView.xaml.cs | 71 ++++
.../LapReviewViewModel.ContactAnalysis.cs | 29 ++
src/Wisp.App/Runs/LapReviewViewModel.Map.cs | 143 ++++++++
src/Wisp.App/Runs/LapReviewViewModel.cs | 21 +-
src/Wisp.App/Runs/RunRecordingService.cs | 2 +-
src/Wisp.App/Runs/RunStore.Contacts.cs | 34 ++
src/Wisp.App/Runs/RunStore.cs | 23 +-
src/Wisp.App/Runs/RunsViewModel.Laps.cs | 6 +
src/Wisp.App/Runs/RunsViewModel.Metadata.cs | 3 +
src/Wisp.Core/Runs/LapReviewContacts.cs | 152 ++++++++
src/Wisp.Core/Runs/RunModels.cs | 5 +-
src/Wisp.Core/VehicleState.cs | 4 +
src/Wisp.Telemetry/Fh6PacketLayout.cs | 2 +
src/Wisp.Telemetry/Fh6PacketParser.cs | 13 +
.../Wisp.App.Tests/LapContactShutdownTests.cs | 168 +++++++++
.../LapReviewMapChannelsTests.cs | 49 +++
tests/Wisp.App.Tests/LapReviewTrack3DTests.cs | 174 +++++++++
.../LapReviewTrackGeometryTests.cs | 176 +++++++++
tests/Wisp.App.Tests/RunStoreContactsTests.cs | 243 +++++++++++++
.../Wisp.App.Tests/RunTuneAttachmentTests.cs | 25 +-
.../Wisp.Core.Tests/LapReviewContactsTests.cs | 201 +++++++++++
.../SmashablePacketTests.cs | 50 +++
.../LapReview3DPerformanceReview.cs | 268 ++++++++++++++
tools/Wisp.UiReview/LapReviewFixtures.cs | 2 +-
tools/Wisp.UiReview/LapReviewUiChecks.cs | 166 ++++++++-
tools/Wisp.UiReview/Program.cs | 6 +
35 files changed, 2932 insertions(+), 72 deletions(-)
create mode 100644 docs/LAP-REVIEW-3D-PRIVATE.md
create mode 100644 src/Wisp.App/Runs/LapReviewPalette.cs
create mode 100644 src/Wisp.App/Runs/LapReviewTrack3D.cs
create mode 100644 src/Wisp.App/Runs/LapReviewTrackGeometry.cs
create mode 100644 src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs
create mode 100644 src/Wisp.App/Runs/LapReviewViewModel.Map.cs
create mode 100644 src/Wisp.App/Runs/RunStore.Contacts.cs
create mode 100644 src/Wisp.Core/Runs/LapReviewContacts.cs
create mode 100644 tests/Wisp.App.Tests/LapContactShutdownTests.cs
create mode 100644 tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs
create mode 100644 tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
create mode 100644 tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
create mode 100644 tests/Wisp.App.Tests/RunStoreContactsTests.cs
create mode 100644 tests/Wisp.Core.Tests/LapReviewContactsTests.cs
create mode 100644 tests/Wisp.Telemetry.Tests/SmashablePacketTests.cs
create mode 100644 tools/Wisp.UiReview/LapReview3DPerformanceReview.cs
diff --git a/docs/LAP-REVIEW-3D-PRIVATE.md b/docs/LAP-REVIEW-3D-PRIVATE.md
new file mode 100644
index 0000000..2096ec9
--- /dev/null
+++ b/docs/LAP-REVIEW-3D-PRIVATE.md
@@ -0,0 +1,50 @@
+# Lap review 3D private test
+
+The 2D review stays the default. 3D uses saved XYZ positions with the same scale
+on every axis; the ground projection is a depth cue, not a terrain reconstruction.
+Camera and cursor changes reuse frozen geometry. Scene preparation is cancellable
+and runs off the UI thread. Dense display paths retain height and selected-channel
+extrema, while cursor and analysis retain the original full-resolution samples.
+
+All fourteen existing channels, wheel selection, Run B / benchmark comparison,
+shared cursor, selected sections, readouts and graph handoff are retained.
+Horsepower, torque and elevation are additional channels. PNG export includes
+the current camera, channel legend and selected-point value, uses a bounded image,
+and saves atomically without overwriting existing files.
+
+## Contact evidence
+
+The official FH6 Data Out layout documents SmashableVelDiff and SmashableMass:
+https://support.forza.net/hc/en-us/articles/51744149102611-Forza-Horizon-6-Data-Out-Documentation
+
+These fields report breakable-object velocity loss and mass, not a general
+wall/car collision flag. Object contact requires a continuous observed
+zero-to-positive impulse. Possible contact is a conservative movement estimate;
+manual Contact markers remain available. No universal collision accuracy is claimed.
+Historical runs discarded these fields and cannot retrospectively recover them.
+
+New journals declare run schema 3 before recording samples, preventing older
+readers from recovering a partial prefix and discarding newer contact data.
+Legacy v1/v2 runs stay readable and retain their format when editing annotations.
+This private build or newer is required to open newly recorded v3 runs.
+
+## Platform assessment
+
+Steam: saved-run visualization uses no game memory or executable-specific data.
+Object telemetry uses FH6's documented 324-byte Data Out packet contract.
+Xbox app / Microsoft Store: the same visualization and packet contract applies;
+no platform-specific native reader was changed. No new live-game collision capture
+was performed on either edition. Packet fixtures establish parsing, not real-world
+coverage of walls, other cars, repeated impacts or dropped packets.
+
+## Verification boundary
+
+Focused checks cover existing channels and interactions, 3D projection/elevation,
+color pixels, stale asynchronous scene protection, contact annotation persistence,
+shutdown during save, schema recovery/import/export and PNG output. The private
+installer retains the standard full packaging gates. Detailed results and source
+revision are retained with the delivered installer.
+
+The stored-lap performance check uses one existing run read-only and a synthetic
+180,000-point size-limit case. CPU preparation and software-render timing do not
+establish gameplay FPS or guarantee every machine's interaction performance.
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index a66ec89..17825fa 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -1,38 +1,52 @@
-Wisp 2.6.3 Compatibility and drift test
-Private build ID: adaptive-drift-20261006-01
-
-This is a private test build, not a published release. It updates your existing
-Wisp installation and keeps settings, profiles, calibrations, saved runs and
-the Clips library. It is not a separate installation.
-
-Changes in this build
-- Restores Drift Zone angle-bonus guidance for the current Steam and Xbox PC
- builds. If bonus guidance is unavailable, the gauge still shows your angle.
-- A dismissible note from Views appears when opening Wisp, at most once per
- 24 hours. It stays open until you press its X. Its GitHub repo button opens
- Views2k/Wisp so you can leave a star without dismissing the note.
-- Wisp can recognize unchanged HUD and Tune readers after a Forza update on
- both Steam and Xbox PC. A changed version number alone no longer blocks them.
-- Game identity, reader structure, current-car ownership and value checks remain
- required. If an optional reader changes, the verified features stay available.
-- Temporary read failures can be retried without restarting Wisp.
+Wisp 2.6.4 Lap review 3D test
+Private build ID: lap-review-3d-20261006-01
+
+This is a private test build, not a public release. It updates your existing
+Wisp installation and keeps your settings, profiles, calibrations, runs and
+clips. It is not a separate installation.
+
+Changes
+- Switch between the existing 2D lap map and a 3D view of recorded elevation.
+- Rotate, tilt, roll, pan and zoom the 3D map; reset the camera at any time.
+- Keep every existing map/graph channel, wheel selector, comparison, cursor,
+ section analysis and graph shortcut. Add horsepower, torque and elevation.
+- Read the color legend and the selected point's value next to the map.
+- Show reported object contacts, possible impacts and saved manual markers.
+- Save the current 2D or 3D map, including its legend, as a PNG image.
+
+Controls
+Drag: rotate. Shift-drag or right-drag: pan. Ctrl-drag: roll.
+Scroll: zoom. Double-click or Reset view: restore the camera.
+Click the line: select a point. Arrow keys: move along the lap.
+Home/End: first/last point. Camera controls also provide axis sliders.
Suggested checks
-1. Start your usual Steam or Xbox PC version of Forza, then Wisp. Drive into the
- open world and check the HUD against the car and the game's displayed values.
-2. Open Tune and refresh. Compare the displayed values with your current tune.
-3. Switch cars and confirm the HUD and Tune show the new car's values.
-4. Close and reopen Forza with Wisp still running. Confirm Wisp reconnects.
-5. Save and play a short clip with your existing recording settings.
-6. Check Drift Zone angle-bonus guidance and Custom target. The selected mode,
- target, gauge position and appearance should stay as you set them.
-
-If a read fails, use Copy details on Tune or export a debug ZIP from Diagnostics.
-Include the private build ID above with your report. This build does not enable
-per-frame diagnostic logging or change your recording settings.
-
-Steam and Xbox read-only comparisons passed on the October 5 game builds.
-Unknown future builds still need to pass the same validation; changed game
-readers, inaccessible memory or ambiguous matches must be rejected.
-Drift angle readings use Data Out. Automatic HUD/Tune validation does not verify
-new Drift Zone scoring rules; unverified rules leave the gauge in Angle only.
+1. Open an existing saved lap in Runs and expand Lap review.
+2. Switch between 2D and 3D. Rotate and zoom, then reset the view.
+3. Change channels and wheels. Select a point, compare Run B, select a section
+ and open it in graphs. The selected point/section should stay consistent.
+4. Mark a known contact, reopen the run and remove the marker.
+5. Save a PNG in both modes. Check the current view, colors and legend.
+6. Record a new lap on your usual Steam or Xbox PC installation. Object
+ contacts use the same Data Out fields on both editions.
+
+Contact coverage
+Forza reports velocity loss and mass for breakable-object impacts. Those
+appear as Object contact in new recordings. Possible contact is an estimate
+from movement; it is not a verified wall/car collision. Old recordings did
+not retain the object-impact fields. Mark known contacts where needed.
+
+Saved-run compatibility
+Existing saved runs remain readable and keep their original format. New runs
+with object-contact telemetry use run format 3 and require this build or newer.
+If you reinstall public 2.6.4, those new runs are kept on disk but cannot be
+opened there. Return to this build to review them. Exported runs keep the same
+version requirement.
+
+The 3D line uses recorded world positions at equal scale on all axes. Its floor
+projection is a visual reference, not a terrain model. Dense laps are simplified
+for display while keeping original samples for analysis and cursor selection.
+
+Include the private build ID above with feedback. Use Diagnostics to export
+redacted debug details if needed. No game-memory readers or recording settings
+are changed by this build.
diff --git a/src/Wisp.App/DebugLogging/HealthContextRecorder.cs b/src/Wisp.App/DebugLogging/HealthContextRecorder.cs
index 7d7ed16..81f97fa 100644
--- a/src/Wisp.App/DebugLogging/HealthContextRecorder.cs
+++ b/src/Wisp.App/DebugLogging/HealthContextRecorder.cs
@@ -9,7 +9,7 @@ internal enum HealthEventCode
{
ApplicationStarted, ApplicationStopping, RecorderChanged, PlayerChanged,
ClipSelected, ClipClosed, ClipExportStarted, ClipExportFinished,
- ClipActionFailed, TuneReadFailed, SettingsSaveFailed, RendererFailed, HealthCollectionFailed
+ ClipActionFailed, TuneReadFailed, SettingsSaveFailed, RendererFailed, HealthCollectionFailed, LapReviewFailed
}
internal enum HealthPlayerState { Closed, Preparing, ReadyPaused, Playing, Buffering, Ended, Failed }
diff --git a/src/Wisp.App/Laps/LapRunCapture.cs b/src/Wisp.App/Laps/LapRunCapture.cs
index 38d90e9..18707d9 100644
--- a/src/Wisp.App/Laps/LapRunCapture.cs
+++ b/src/Wisp.App/Laps/LapRunCapture.cs
@@ -88,6 +88,7 @@ internal void Observe(VehicleState state, LapTimingMode timing, LapCaptureFrame?
{
var run = new RecordedRun
{
+ SchemaVersion = RecordedRun.CurrentSchemaVersion,
Name = AutomaticLapName(_samples[0].State, timing),
StartedAtUtc = _samples[0].State.ReceivedAtUtc,
FinishReason = timing == LapTimingMode.TimeAttack ? "Completed Time Attack lap" : "Completed game lap",
diff --git a/src/Wisp.App/Runs/LapReviewPalette.cs b/src/Wisp.App/Runs/LapReviewPalette.cs
new file mode 100644
index 0000000..a9d6bae
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewPalette.cs
@@ -0,0 +1,55 @@
+using System.Windows;
+using System.Windows.Media;
+
+namespace Wisp.App.Runs;
+
+public static class LapReviewPalette
+{
+ private static readonly Color[] Stops = [Color.FromRgb(52, 120, 246), Color.FromRgb(24, 203, 220),
+ Color.FromRgb(80, 224, 113), Color.FromRgb(246, 213, 69), Color.FromRgb(248, 79, 67)];
+ private static readonly Brush[] Brushes = Enumerable.Range(0, 64).Select(i =>
+ {
+ var brush = new SolidColorBrush(GetColor(i / 63d));
+ brush.Freeze();
+ return (Brush)brush;
+ }).ToArray();
+ public static Brush LegendBrush { get; } = CreateLegend();
+ public static Brush ContactBrush { get; } = Freeze(Color.FromRgb(255, 105, 116));
+ public static Brush StartBrush { get; } = Freeze(Color.FromRgb(46, 220, 175));
+
+ internal static Color GetColor(double fraction)
+ {
+ var scaled = Math.Clamp(double.IsFinite(fraction) ? fraction : 0, 0, 1) * (Stops.Length - 1);
+ var lower = Math.Min((int)scaled, Stops.Length - 2);
+ var t = scaled - lower;
+ var a = Stops[lower]; var b = Stops[lower + 1];
+ return Color.FromRgb((byte)Math.Round(a.R + (b.R - a.R) * t),
+ (byte)Math.Round(a.G + (b.G - a.G) * t), (byte)Math.Round(a.B + (b.B - a.B) * t));
+ }
+
+ internal static Brush GetBrush(double fraction) => Brushes[Math.Clamp((int)Math.Round(
+ (double.IsFinite(fraction) ? fraction : 0) * 63), 0, 63)];
+
+ private static Brush CreateLegend()
+ {
+ var brush = new LinearGradientBrush { StartPoint = new Point(0, .5), EndPoint = new Point(1, .5) };
+ for (var i = 0; i < Stops.Length; i++) brush.GradientStops.Add(new(Stops[i], i / (double)(Stops.Length - 1)));
+ brush.Freeze();
+ return brush;
+ }
+ private static Brush Freeze(Color color) { var brush = new SolidColorBrush(color); brush.Freeze(); return brush; }
+}
+
+internal readonly record struct LapReviewColorRange(double Minimum, double Maximum, bool HasValues)
+{
+ internal double Fraction(double value) => Maximum > Minimum ? Math.Clamp((value - Minimum) / (Maximum - Minimum), 0, 1) : .5;
+ internal static LapReviewColorRange From(LapReviewPlotData data)
+ {
+ var minimum = double.PositiveInfinity; var maximum = double.NegativeInfinity;
+ if (data.Lap is { } lap)
+ for (var i = 0; i < lap.Points.Length; i++)
+ if (LapReviewPlot.Value(lap.Points[i], data, i) is { } value && double.IsFinite(value))
+ { minimum = Math.Min(minimum, value); maximum = Math.Max(maximum, value); }
+ return double.IsFinite(minimum) ? new(minimum, maximum, true) : new(0, 0, false);
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewPlot.cs b/src/Wisp.App/Runs/LapReviewPlot.cs
index 8458bf4..222ec84 100644
--- a/src/Wisp.App/Runs/LapReviewPlot.cs
+++ b/src/Wisp.App/Runs/LapReviewPlot.cs
@@ -9,7 +9,8 @@ namespace Wisp.App.Runs;
public sealed record LapReviewPlotData(LapReviewLap? Lap, LapReviewLap? Reference,
LapReviewComparison? Comparison, LapReviewChannel Channel, SpeedUnit SpeedUnit, int Cursor, int SectionStart, int SectionEnd,
- int Wheel = 0, TireTemperatureUnit TemperatureUnit = TireTemperatureUnit.Fahrenheit);
+ int Wheel = 0, TireTemperatureUnit TemperatureUnit = TireTemperatureUnit.Fahrenheit,
+ TorqueUnit TorqueUnit = TorqueUnit.NewtonMeters, IReadOnlyList? Contacts = null);
public sealed class LapReviewPlot : FrameworkElement
{
@@ -25,12 +26,6 @@ public sealed class LapReviewPlot : FrameworkElement
private double _preparedDpi;
private bool _preparedMap;
private Brush? _preparedText, _preparedMuted, _preparedBackground;
- private static readonly Brush[] Heat = Enumerable.Range(0, 64).Select(i =>
- {
- var t = i / 63d;
- var brush = new SolidColorBrush(Color.FromRgb((byte)(60 + 190 * t), (byte)(200 - 60 * t), (byte)(245 - 150 * t)));
- brush.Freeze(); return (Brush)brush;
- }).ToArray();
public LapReviewPlot() { Focusable = true; ClipToBounds = true; Cursor = Cursors.Cross; }
protected override void OnRender(DrawingContext dc)
{
@@ -63,7 +58,7 @@ protected override void OnRender(DrawingContext dc)
private static bool SamePlot(LapReviewPlotData? a, LapReviewPlotData b) => a is not null &&
ReferenceEquals(a.Lap, b.Lap) && ReferenceEquals(a.Reference, b.Reference) && ReferenceEquals(a.Comparison, b.Comparison) &&
a.Channel == b.Channel && a.SpeedUnit == b.SpeedUnit && a.SectionStart == b.SectionStart && a.SectionEnd == b.SectionEnd &&
- a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit;
+ a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit && a.TorqueUnit == b.TorqueUnit && ReferenceEquals(a.Contacts, b.Contacts);
private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Brush background)
{
_hitPoints.Clear();
@@ -74,6 +69,8 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
if (!IsMap) values = values.Concat(Enumerable.Range(0, points.Length).Select(index => ReferenceValue(data, index)));
var finite = values.Where(value => value is { } number && double.IsFinite(number)).Select(value => value!.Value).ToArray();
var minimum = finite.Length == 0 ? 0 : finite.Min(); var maximum = finite.Length == 0 ? 1 : finite.Max();
+ var colorMinimum = minimum; var colorMaximum = maximum;
+ var constant = maximum == minimum;
if (maximum - minimum < .001) maximum = minimum + 1;
Func project;
if (IsMap)
@@ -105,7 +102,7 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
if (i % stride != 0 && i != points.Length - 1) continue;
if (prior is { } previous && !broken && (IsMap || valid))
{
- var brush = IsMap && valid ? Heat[Math.Clamp((int)((value!.Value - minimum) / (maximum - minimum) * 63), 0, 63)] : IsMap ? muted : RunComparisonColors.RunA;
+ var brush = IsMap && valid ? LapReviewPalette.GetBrush(constant ? .5 : (value!.Value - colorMinimum) / (colorMaximum - colorMinimum)) : IsMap ? muted : RunComparisonColors.RunA;
var selected = i >= data.SectionStart && i <= data.SectionEnd;
dc.DrawLine(new(brush, selected ? 3 : 1.2), previous, p);
}
@@ -125,9 +122,20 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
previous = point;
}
}
- if (IsMap) dc.DrawEllipse(null, new(RunComparisonColors.RunA, 2), project(points[0]), 6, 6);
+ if (IsMap) dc.DrawEllipse(null, new(LapReviewPalette.StartBrush, 2), project(points[0]), 6, 6);
+ if (data.Contacts is { } contacts)
+ foreach (var contact in contacts)
+ {
+ if (contact.PointIndex < 0 || contact.PointIndex >= _hitPoints.Count) continue;
+ var p = _hitPoints[contact.PointIndex].Position;
+ if (!IsMap) p.Y = area.Top + 7;
+ var marker = new StreamGeometry();
+ using (var shape = marker.Open()) { shape.BeginFigure(new Point(p.X, p.Y - 5), true, true); shape.LineTo(new Point(p.X + 5, p.Y + 4), true, false); shape.LineTo(new Point(p.X - 5, p.Y + 4), true, false); }
+ marker.Freeze();
+ dc.DrawGeometry(LapReviewPalette.ContactBrush, new Pen(background, 1), marker);
+ }
var unit = Unit(data);
- Label(dc, finite.Length == 0 ? "No comparable values for this channel" : $"{minimum:0.##} → {maximum:0.##} {unit}" + (IsMap ? " · low = blue, high = amber" : " · lap = cyan, reference = amber"), new(10, 5), muted);
+ Label(dc, finite.Length == 0 ? "No comparable values for this channel" : $"{colorMinimum:0.##} → {colorMaximum:0.##} {unit}" + (IsMap ? " · low = blue, high = red" : " · lap = cyan, reference = amber"), new(10, 5), muted);
Label(dc, IsMap ? "Recorded line · start ring · click or use arrow keys" : $"Distance from lap start · 0–{data.Lap.RecordedDistanceMeters:0} m", new(10, ActualHeight - 21), muted);
}
private static void DrawPath(DrawingContext dc, LapReviewPoint[] points, Func project, Pen pen)
@@ -166,6 +174,9 @@ private static void DrawPath(DrawingContext dc, LapReviewPoint[] points, Func Math.Sqrt(Math.Pow(state.LateralAccelerationMetersPerSecondSquared, 2) + Math.Pow(state.LongitudinalAccelerationMetersPerSecondSquared, 2)) / 9.80665,
LapReviewChannel.Rpm => state.EngineRpm,
LapReviewChannel.Gear => (int)state.Gear >= 0 ? (int)state.Gear : null,
+ LapReviewChannel.Power => float.IsFinite(state.PowerWatts) ? state.PowerWatts / 745.699871582 : null,
+ LapReviewChannel.Torque => float.IsFinite(state.TorqueNm) ? state.TorqueNm * (data.TorqueUnit == TorqueUnit.NewtonMeters ? 1 : .7375621493) : null,
+ LapReviewChannel.Elevation => float.IsFinite(point.Position.Y) ? point.Position.Y : null,
LapReviewChannel.TireTemperature => data.TemperatureUnit == TireTemperatureUnit.Celsius ? (WheelValue(state.TireTemperatureFahrenheit, data.Wheel) - 32) * 5 / 9 : WheelValue(state.TireTemperatureFahrenheit, data.Wheel),
LapReviewChannel.SlipRatio => WheelValue(state.TireSlipRatio, data.Wheel),
LapReviewChannel.SlipAngle => WheelValue(state.TireSlipAngle, data.Wheel),
@@ -174,7 +185,7 @@ private static void DrawPath(DrawingContext dc, LapReviewPoint[] points, Func wheel switch { 1 => values.FrontRight, 2 => values.RearLeft, 3 => values.RearRight, _ => values.FrontLeft };
- private static string Unit(LapReviewPlotData data) => data.Channel switch { LapReviewChannel.Speed => RunPresentation.SpeedLabel(data.SpeedUnit), LapReviewChannel.Delta => "s (lap − reference)", LapReviewChannel.Throttle or LapReviewChannel.Brake or LapReviewChannel.Steering => "%", LapReviewChannel.Rpm => "rpm", LapReviewChannel.Gear => "gear", LapReviewChannel.TireTemperature => data.TemperatureUnit == TireTemperatureUnit.Celsius ? "°C" : "°F", LapReviewChannel.SlipRatio or LapReviewChannel.SlipAngle => "raw", LapReviewChannel.Suspension => "normalized", _ => "g" };
+ internal static string Unit(LapReviewPlotData data) => data.Channel switch { LapReviewChannel.Speed => RunPresentation.SpeedLabel(data.SpeedUnit), LapReviewChannel.Delta => "s (lap − reference)", LapReviewChannel.Throttle or LapReviewChannel.Brake or LapReviewChannel.Steering => "%", LapReviewChannel.Rpm => "rpm", LapReviewChannel.Gear => "gear", LapReviewChannel.Power => "hp", LapReviewChannel.Torque => data.TorqueUnit == TorqueUnit.NewtonMeters ? "Nm" : "lb-ft", LapReviewChannel.Elevation => "m", LapReviewChannel.TireTemperature => data.TemperatureUnit == TireTemperatureUnit.Celsius ? "°C" : "°F", LapReviewChannel.SlipRatio or LapReviewChannel.SlipAngle => "raw", LapReviewChannel.Suspension => "normalized", _ => "g" };
protected override void OnMouseDown(MouseButtonEventArgs e) { base.OnMouseDown(e); if (e.ChangedButton != MouseButton.Left) return; Focus(); CaptureMouse(); Choose(e.GetPosition(this)); e.Handled = true; }
protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (IsMouseCaptured && e.LeftButton == MouseButtonState.Pressed) Choose(e.GetPosition(this)); }
protected override void OnMouseUp(MouseButtonEventArgs e) { base.OnMouseUp(e); if (IsMouseCaptured) ReleaseMouseCapture(); }
diff --git a/src/Wisp.App/Runs/LapReviewTrack3D.cs b/src/Wisp.App/Runs/LapReviewTrack3D.cs
new file mode 100644
index 0000000..e9a62fe
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewTrack3D.cs
@@ -0,0 +1,297 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Media3D;
+using Wisp.App.DebugLogging;
+
+namespace Wisp.App.Runs;
+
+public sealed class LapReviewTrack3D : Grid
+{
+ public static readonly DependencyProperty DataProperty = DependencyProperty.Register(nameof(Data), typeof(LapReviewPlotData), typeof(LapReviewTrack3D),
+ new FrameworkPropertyMetadata(null, DataChanged));
+ public static readonly DependencyProperty YawProperty = AngleProperty(nameof(Yaw), 0, false);
+ public static readonly DependencyProperty PitchProperty = AngleProperty(nameof(Pitch), LapReviewTrackFit.DefaultPitch, true);
+ public static readonly DependencyProperty RollProperty = AngleProperty(nameof(Roll), 0, false);
+ public static readonly DependencyProperty ZoomFactorProperty = DependencyProperty.Register(nameof(ZoomFactor), typeof(double), typeof(LapReviewTrack3D),
+ new FrameworkPropertyMetadata(1d, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, CameraChanged,
+ (_, value) => double.IsFinite((double)value) ? Math.Clamp((double)value, .2, 50) : 1d));
+ public static readonly DependencyProperty ShowContactsProperty = DependencyProperty.Register(nameof(ShowContacts), typeof(bool), typeof(LapReviewTrack3D),
+ new FrameworkPropertyMetadata(true));
+ private static readonly DependencyPropertyKey IsPreparingPropertyKey = DependencyProperty.RegisterReadOnly(nameof(IsPreparing), typeof(bool), typeof(LapReviewTrack3D), new PropertyMetadata(false));
+ public static readonly DependencyProperty IsPreparingProperty = IsPreparingPropertyKey.DependencyProperty;
+ private static readonly DependencyPropertyKey StatusTextPropertyKey = DependencyProperty.RegisterReadOnly(nameof(StatusText), typeof(string), typeof(LapReviewTrack3D), new PropertyMetadata(string.Empty));
+ public static readonly DependencyProperty StatusTextProperty = StatusTextPropertyKey.DependencyProperty;
+
+ public LapReviewPlotData? Data { get => (LapReviewPlotData?)GetValue(DataProperty); set => SetValue(DataProperty, value); }
+ public double Yaw { get => (double)GetValue(YawProperty); set => SetValue(YawProperty, value); }
+ public double Pitch { get => (double)GetValue(PitchProperty); set => SetValue(PitchProperty, value); }
+ public double Roll { get => (double)GetValue(RollProperty); set => SetValue(RollProperty, value); }
+ public double ZoomFactor { get => (double)GetValue(ZoomFactorProperty); set => SetValue(ZoomFactorProperty, value); }
+ public bool ShowContacts { get => (bool)GetValue(ShowContactsProperty); set => SetValue(ShowContactsProperty, value); }
+ public bool IsPreparing => (bool)GetValue(IsPreparingProperty);
+ public string StatusText => (string)GetValue(StatusTextProperty);
+ public bool IsReady => _scene is not null && _path.Content is not null && Data is { } data &&
+ SameScene(_preparedData, data) && _preparedContacts == ShowContacts && !IsPreparing && string.IsNullOrEmpty(StatusText);
+ public event Action? PointChosen;
+
+ private readonly Viewport3D _viewport = new() { IsHitTestVisible = false, ClipToBounds = true };
+ private readonly OrthographicCamera _camera = new() { NearPlaneDistance = .01, FarPlaneDistance = 20 };
+ private readonly ModelVisual3D _path = new();
+ private readonly ModelVisual3D _cursor = new();
+ private readonly TranslateTransform3D _cursorPosition = new();
+ private readonly TextBlock _status = new() { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false };
+ private LapReviewTrackScene? _scene;
+ private LapReviewPlotData? _preparedData, _requestedData;
+ private CancellationTokenSource? _preparation;
+ private Task _preparationTask = Task.CompletedTask;
+ private bool _preparedContacts, _requestedContacts;
+ private Point3D _target;
+ private Point _dragStart, _dragPrevious;
+ private bool _dragMoved;
+ private MouseButton _dragButton;
+ private bool _testPreparation;
+ internal int SceneBuildCount { get; private set; }
+ internal int PreparedSegmentCount => _scene?.SegmentCount ?? 0;
+
+ public LapReviewTrack3D()
+ {
+ Focusable = true;
+ ClipToBounds = true;
+ Cursor = Cursors.Hand;
+ Background = Brushes.Transparent;
+ _viewport.Camera = _camera;
+ var ambient = new AmbientLight(Colors.White); ambient.Freeze();
+ _viewport.Children.Add(new ModelVisual3D { Content = ambient });
+ _viewport.Children.Add(_path);
+ _cursor.Content = LapReviewTrackGeometry.CursorModel();
+ _cursor.Transform = _cursorPosition;
+ _viewport.Children.Add(_cursor);
+ _status.SetResourceReference(TextBlock.ForegroundProperty, "MutedBrush");
+ Children.Add(_viewport);
+ Children.Add(_status);
+ Loaded += (_, _) => RefreshScene();
+ Unloaded += (_, _) => CancelPreparation();
+ IsVisibleChanged += (_, _) => { if (IsVisible) RefreshScene(); else CancelPreparation(); };
+ SizeChanged += (_, _) => UpdateCamera();
+ UpdateCamera();
+ }
+
+ public void ResetView()
+ {
+ _target = _scene?.Fit.Target ?? new();
+ SetCurrentValue(YawProperty, _scene?.Fit.Yaw ?? 0d);
+ SetCurrentValue(PitchProperty, LapReviewTrackFit.DefaultPitch);
+ SetCurrentValue(RollProperty, 0d);
+ SetCurrentValue(ZoomFactorProperty, 1d);
+ UpdateCamera();
+ }
+
+ public void ZoomBy(double factor)
+ {
+ if (double.IsFinite(factor) && factor > 0) SetCurrentValue(ZoomFactorProperty, ZoomFactor * factor);
+ }
+
+ public void PanBy(double horizontalPixels, double verticalPixels)
+ {
+ if (!double.IsFinite(horizontalPixels) || !double.IsFinite(verticalPixels)) return;
+ var frame = LapReviewCameraFrame.From(Yaw, Pitch, Roll);
+ var scale = _camera.Width / Math.Max(1, ActualWidth);
+ var target = _target - frame.Right * horizontalPixels * scale + frame.Up * verticalPixels * scale;
+ if (!double.IsFinite(target.X) || !double.IsFinite(target.Y) || !double.IsFinite(target.Z)) return;
+ _target = target;
+ UpdateCamera();
+ }
+
+ private static DependencyProperty AngleProperty(string name, double initial, bool pitch) => DependencyProperty.Register(name, typeof(double), typeof(LapReviewTrack3D),
+ new FrameworkPropertyMetadata(initial, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, CameraChanged,
+ (_, value) => CoerceAngle((double)value, initial, pitch)));
+ private static double CoerceAngle(double value, double initial, bool pitch)
+ {
+ if (!double.IsFinite(value)) return initial;
+ if (pitch) return Math.Clamp(value, -90, 90);
+ var normalized = value % 360;
+ return normalized > 180 ? normalized - 360 : normalized < -180 ? normalized + 360 : normalized;
+ }
+ private static void CameraChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => ((LapReviewTrack3D)sender).UpdateCamera();
+ private static void DataChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
+ {
+ var track = (LapReviewTrack3D)sender;
+ var previous = (LapReviewPlotData?)args.OldValue; var next = (LapReviewPlotData?)args.NewValue;
+ if (!ReferenceEquals(previous?.Lap, next?.Lap))
+ {
+ track._scene = null; track._preparedData = null;
+ track.ResetView();
+ }
+ track.RefreshScene();
+ }
+
+ protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
+ {
+ base.OnPropertyChanged(e);
+ if (e.Property == ShowContactsProperty) RefreshScene();
+ if (e.Property == FocusCues.ShowKeyboardFocusProperty) InvalidateVisual();
+ }
+
+ private static bool SameScene(LapReviewPlotData? a, LapReviewPlotData b) => a is not null &&
+ ReferenceEquals(a.Lap, b.Lap) && ReferenceEquals(a.Reference, b.Reference) && ReferenceEquals(a.Comparison, b.Comparison) &&
+ ReferenceEquals(a.Contacts, b.Contacts) && a.Channel == b.Channel && a.SpeedUnit == b.SpeedUnit && a.TorqueUnit == b.TorqueUnit &&
+ a.SectionStart == b.SectionStart && a.SectionEnd == b.SectionEnd && a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit;
+
+ private void RefreshScene()
+ {
+ if ((!IsLoaded || !IsVisible) && !_testPreparation) return;
+ if (Data is not { Lap.Points.Length: > 1 } data)
+ {
+ CancelPreparation(); _scene = null; _preparedData = null; _path.Content = null; _cursor.Content = null;
+ SetStatus("No recorded lap positions", false); return;
+ }
+ if (_scene is not null && SameScene(_preparedData, data) && _preparedContacts == ShowContacts)
+ { CancelPreparation(); _path.Content = _scene.Model; SetStatus(string.Empty, false); UpdateCursor(); return; }
+ if (_preparation is not null && SameScene(_requestedData, data) && _requestedContacts == ShowContacts) return;
+ CancelPreparation();
+ var cancellation = new CancellationTokenSource(); _preparation = cancellation;
+ _requestedData = data; _requestedContacts = ShowContacts;
+ _path.Content = null; _cursor.Content = null;
+ SetStatus("Preparing 3D map…", true);
+ _preparationTask = PrepareAsync(data, ShowContacts, cancellation);
+ }
+
+ private async Task PrepareAsync(LapReviewPlotData data, bool showContacts, CancellationTokenSource cancellation)
+ {
+ try
+ {
+ var scene = await Task.Run(() => LapReviewTrackGeometry.Build(data, showContacts, cancellation.Token), cancellation.Token);
+ if (cancellation.IsCancellationRequested || !ReferenceEquals(_preparation, cancellation)) return;
+ var firstScene = _scene is null;
+ _scene = scene; _preparedData = data; _preparedContacts = showContacts;
+ if (firstScene) ResetView();
+ _path.Content = scene.Model; SceneBuildCount++;
+ SetStatus(string.Empty, false); UpdateCursor(); UpdateCamera();
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { }
+ catch (Exception exception)
+ {
+ if (!ReferenceEquals(_preparation, cancellation)) return;
+ _scene = null; _preparedData = null; _path.Content = null; _cursor.Content = null;
+ HealthContextRecorder.Current.RecordBreadcrumb(HealthEventCode.LapReviewFailed, exception.HResult);
+ SetStatus("The 3D map could not be prepared. Switch to 2D or reopen this lap.", false);
+ }
+ finally
+ {
+ if (ReferenceEquals(_preparation, cancellation)) { _preparation = null; _requestedData = null; }
+ cancellation.Dispose();
+ }
+ }
+
+ private void CancelPreparation()
+ {
+ _preparation?.Cancel(); _preparation = null; _requestedData = null;
+ if (IsPreparing) SetStatus(string.Empty, false);
+ }
+
+ private void SetStatus(string message, bool preparing)
+ {
+ SetValue(IsPreparingPropertyKey, preparing); SetValue(StatusTextPropertyKey, message);
+ _status.Text = message; _status.Visibility = message.Length == 0 ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void UpdateCursor()
+ {
+ if (_scene is null || Data is not { Lap.Points.Length: > 0 } data) { _cursor.Content = null; return; }
+ var position = data.Lap.Points[Math.Clamp(data.Cursor, 0, data.Lap.Points.Length - 1)].Position;
+ if (!LapReviewTrackBounds.IsFinite(position)) { _cursor.Content = null; return; }
+ _cursor.Content ??= LapReviewTrackGeometry.CursorModel();
+ var p = _scene.Bounds.Normalize(position);
+ _cursorPosition.OffsetX = p.X; _cursorPosition.OffsetY = p.Y; _cursorPosition.OffsetZ = p.Z;
+ }
+
+ private void UpdateCamera()
+ {
+ if (_camera is null) return;
+ var frame = LapReviewCameraFrame.From(Yaw, Pitch, Roll);
+ _camera.Position = _target - frame.Forward * 4;
+ _camera.LookDirection = frame.Forward;
+ _camera.UpDirection = frame.Up;
+ _camera.Width = (_scene?.Fit.Width(ActualWidth / Math.Max(1, ActualHeight)) ?? 1.5) / ZoomFactor;
+ }
+
+ internal Task PrepareForTestAsync()
+ {
+ _testPreparation = true;
+ RefreshScene();
+ return _preparationTask;
+ }
+
+ internal Point ProjectPoint(int index)
+ {
+ if (_scene is null || Data?.Lap is not { } lap || (uint)index >= (uint)lap.Points.Length) return new(double.NaN, double.NaN);
+ return LapReviewCameraFrame.From(Yaw, Pitch, Roll).Project(_scene.Bounds.Normalize(lap.Points[index].Position), _target, _camera.Width, RenderSize);
+ }
+
+ private void Choose(Point click)
+ {
+ if (!IsReady || Data?.Lap is not { } lap || _scene is null) return;
+ var frame = LapReviewCameraFrame.From(Yaw, Pitch, Roll);
+ var closest = double.PositiveInfinity; var index = -1;
+ for (var i = 0; i < lap.Points.Length; i++)
+ {
+ if (!LapReviewTrackBounds.IsFinite(lap.Points[i].Position)) continue;
+ var p = frame.Project(_scene.Bounds.Normalize(lap.Points[i].Position), _target, _camera.Width, RenderSize);
+ var distance = (p - click).LengthSquared;
+ if (distance < closest) { closest = distance; index = i; }
+ }
+ if (index >= 0) PointChosen?.Invoke(index);
+ }
+
+ protected override void OnMouseDown(MouseButtonEventArgs e)
+ {
+ base.OnMouseDown(e);
+ if (e.ChangedButton is not MouseButton.Left and not MouseButton.Right and not MouseButton.Middle) return;
+ Focus();
+ if (e.ChangedButton == MouseButton.Left && e.ClickCount == 2) { ResetView(); e.Handled = true; return; }
+ _dragStart = _dragPrevious = e.GetPosition(this); _dragMoved = false; _dragButton = e.ChangedButton;
+ CaptureMouse(); e.Handled = true;
+ }
+ protected override void OnMouseMove(MouseEventArgs e)
+ {
+ base.OnMouseMove(e);
+ if (!IsMouseCaptured) return;
+ var current = e.GetPosition(this); var delta = current - _dragPrevious;
+ _dragMoved |= (current - _dragStart).LengthSquared > 9;
+ if (!_dragMoved) return;
+ _dragPrevious = current;
+ if (_dragButton is MouseButton.Middle or MouseButton.Right || (Keyboard.Modifiers & ModifierKeys.Shift) != 0) PanBy(delta.X, delta.Y);
+ else if ((Keyboard.Modifiers & ModifierKeys.Control) != 0) SetCurrentValue(RollProperty, Roll + delta.X * .4);
+ else { SetCurrentValue(YawProperty, Yaw - delta.X * .35); SetCurrentValue(PitchProperty, Pitch + delta.Y * .35); }
+ e.Handled = true;
+ }
+ protected override void OnMouseUp(MouseButtonEventArgs e)
+ {
+ base.OnMouseUp(e);
+ if (!IsMouseCaptured || e.ChangedButton != _dragButton) return;
+ if (!_dragMoved && e.ChangedButton == MouseButton.Left) Choose(e.GetPosition(this));
+ ReleaseMouseCapture(); e.Handled = true;
+ }
+ protected override void OnMouseWheel(MouseWheelEventArgs e) { base.OnMouseWheel(e); ZoomBy(Math.Pow(1.15, e.Delta / 120d)); e.Handled = true; }
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key == Key.R) { ResetView(); e.Handled = true; return; }
+ if (e.Key is Key.Add or Key.OemPlus) { ZoomBy(1.2); e.Handled = true; return; }
+ if (e.Key is Key.Subtract or Key.OemMinus) { ZoomBy(1 / 1.2); e.Handled = true; return; }
+ if (Data?.Lap is not { Points.Length: > 0 } lap) return;
+ var next = e.Key switch { Key.Left or Key.Down => Data.Cursor - 1, Key.Right or Key.Up => Data.Cursor + 1, Key.Home => 0, Key.End => lap.Points.Length - 1, _ => -1 };
+ if (next < 0 && e.Key is not Key.Left and not Key.Down) return;
+ PointChosen?.Invoke(Math.Clamp(next, 0, lap.Points.Length - 1)); e.Handled = true;
+ }
+ protected override void OnGotKeyboardFocus(KeyboardFocusChangedEventArgs e) { base.OnGotKeyboardFocus(e); InvalidateVisual(); }
+ protected override void OnLostKeyboardFocus(KeyboardFocusChangedEventArgs e) { base.OnLostKeyboardFocus(e); InvalidateVisual(); }
+ protected override void OnRender(DrawingContext dc)
+ {
+ base.OnRender(dc);
+ if (IsKeyboardFocused && FocusCues.GetShowKeyboardFocus(this) && ActualWidth > 2 && ActualHeight > 2)
+ dc.DrawRectangle(null, new Pen(TryFindResource("TextBrush") as Brush ?? Brushes.White, 1), new Rect(1, 1, ActualWidth - 2, ActualHeight - 2));
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewTrackGeometry.cs b/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
new file mode 100644
index 0000000..b2dcf66
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
@@ -0,0 +1,337 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Media3D;
+using Wisp.Core;
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+internal readonly record struct LapReviewTrackBounds(Point3D Center, double Span, double MinimumHeight)
+{
+ internal Point3D Normalize(LapPosition position) => new((position.X - Center.X) / Span,
+ (position.Y - Center.Y) / Span, -(position.Z - Center.Z) / Span);
+
+ internal static bool IsFinite(LapPosition position) => float.IsFinite(position.X) &&
+ float.IsFinite(position.Y) && float.IsFinite(position.Z);
+
+ internal static LapReviewTrackBounds From(LapReviewPlotData data)
+ {
+ var minimum = new Point3D(double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
+ var maximum = new Point3D(double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity);
+ void Include(LapReviewPoint[] points)
+ {
+ foreach (var point in points)
+ {
+ var p = point.Position;
+ if (!IsFinite(p)) continue;
+ minimum = new(Math.Min(minimum.X, p.X), Math.Min(minimum.Y, p.Y), Math.Min(minimum.Z, p.Z));
+ maximum = new(Math.Max(maximum.X, p.X), Math.Max(maximum.Y, p.Y), Math.Max(maximum.Z, p.Z));
+ }
+ }
+ if (data.Lap is { } lap) Include(lap.Points);
+ if (data.Comparison?.CanCompare == true && data.Reference is { } reference) Include(reference.Points);
+ if (!double.IsFinite(minimum.X)) return new(new(), 1, 0);
+ return new(new((minimum.X + maximum.X) / 2, (minimum.Y + maximum.Y) / 2, (minimum.Z + maximum.Z) / 2),
+ Math.Max(1, Math.Max(maximum.X - minimum.X, Math.Max(maximum.Y - minimum.Y, maximum.Z - minimum.Z))), minimum.Y);
+ }
+}
+
+internal readonly record struct LapReviewCameraFrame(Vector3D Right, Vector3D Up, Vector3D Forward)
+{
+ internal static LapReviewCameraFrame From(double yaw, double pitch, double roll)
+ {
+ var y = yaw * Math.PI / 180; var p = pitch * Math.PI / 180; var r = roll * Math.PI / 180;
+ var forward = new Vector3D(-Math.Sin(y) * Math.Cos(p), -Math.Sin(p), -Math.Cos(y) * Math.Cos(p));
+ var right = new Vector3D(Math.Cos(y), 0, -Math.Sin(y));
+ var up = Vector3D.CrossProduct(right, forward);
+ return new(right * Math.Cos(r) + up * Math.Sin(r), up * Math.Cos(r) - right * Math.Sin(r), forward);
+ }
+
+ internal Point Project(Point3D position, Point3D target, double width, Size viewport)
+ {
+ var offset = position - target;
+ var scale = viewport.Width / width;
+ return new(viewport.Width / 2 + Vector3D.DotProduct(offset, Right) * scale,
+ viewport.Height / 2 - Vector3D.DotProduct(offset, Up) * scale);
+ }
+}
+
+internal readonly record struct LapReviewTrackFit(Point3D Target, double HorizontalSpan, double VerticalSpan, double Yaw)
+{
+ internal const double DefaultPitch = 22;
+ internal double Width(double aspect) => Math.Max(.03, Math.Max(HorizontalSpan, VerticalSpan * Math.Max(.01, aspect))) * 1.12;
+ internal static LapReviewTrackFit From(LapReviewPlotData data, LapReviewTrackBounds bounds)
+ {
+ var yaw = PrincipalYaw(data.Lap?.Points ?? [], bounds);
+ var frame = LapReviewCameraFrame.From(yaw, DefaultPitch, 0);
+ var left = double.PositiveInfinity; var right = double.NegativeInfinity;
+ var bottom = double.PositiveInfinity; var top = double.NegativeInfinity;
+ void IncludePosition(Point3D p)
+ {
+ var vector = p - new Point3D();
+ var horizontal = Vector3D.DotProduct(vector, frame.Right); var vertical = Vector3D.DotProduct(vector, frame.Up);
+ left = Math.Min(left, horizontal); right = Math.Max(right, horizontal);
+ bottom = Math.Min(bottom, vertical); top = Math.Max(top, vertical);
+ }
+ var floor = (bounds.MinimumHeight - bounds.Center.Y) / bounds.Span - .025;
+ void Include(LapReviewPoint[] points, bool shadow)
+ {
+ foreach (var point in points)
+ {
+ if (!LapReviewTrackBounds.IsFinite(point.Position)) continue;
+ var p = bounds.Normalize(point.Position); IncludePosition(p);
+ if (shadow) IncludePosition(new(p.X, floor, p.Z));
+ }
+ }
+ if (data.Lap is { } lap) Include(lap.Points, true);
+ if (data.Comparison?.CanCompare == true && data.Reference is { } reference) Include(reference.Points, false);
+ if (!double.IsFinite(left)) return new(new(), 1, 1, yaw);
+ return new(new Point3D() + frame.Right * ((left + right) / 2) + frame.Up * ((bottom + top) / 2),
+ right - left, top - bottom, yaw);
+ }
+
+ private static double PrincipalYaw(LapReviewPoint[] points, LapReviewTrackBounds bounds)
+ {
+ double total = 0, meanX = 0, meanZ = 0, xx = 0, xz = 0, zz = 0;
+ for (var i = 1; i < points.Length; i++)
+ {
+ if (points[i].BreakBefore || !LapReviewTrackBounds.IsFinite(points[i - 1].Position) || !LapReviewTrackBounds.IsFinite(points[i].Position)) continue;
+ var a = bounds.Normalize(points[i - 1].Position); var b = bounds.Normalize(points[i].Position);
+ var weight = (b - a).Length;
+ if (weight < 1e-9) continue;
+ var x = (a.X + b.X) / 2; var z = (a.Z + b.Z) / 2;
+ var dx = x - meanX; var dz = z - meanZ; var next = total + weight;
+ meanX += dx * weight / next; meanZ += dz * weight / next;
+ xx += weight * dx * (x - meanX); xz += weight * dx * (z - meanZ); zz += weight * dz * (z - meanZ);
+ // Include each segment's spread so even a two-point path has an axis.
+ xx += weight * Math.Pow(b.X - a.X, 2) / 12;
+ xz += weight * (b.X - a.X) * (b.Z - a.Z) / 12;
+ zz += weight * Math.Pow(b.Z - a.Z, 2) / 12;
+ total = next;
+ }
+ if (xx + zz < 1e-12 || Math.Sqrt(Math.Pow(xx - zz, 2) + 4 * xz * xz) < (xx + zz) * .08) return 0;
+ return -Math.Atan2(2 * xz, xx - zz) * 90 / Math.PI;
+ }
+}
+
+internal sealed record LapReviewTrackScene(Model3DGroup Model, LapReviewTrackBounds Bounds, LapReviewTrackFit Fit, int SegmentCount);
+
+internal static class LapReviewTrackGeometry
+{
+ // At most six recorded samples per bucket, including height and channel extrema. The
+ // full-resolution source remains available for cursor selection and analysis.
+ internal const int MaximumBuckets = 1000;
+ internal const int MaximumPathPoints = MaximumBuckets * 6;
+
+ internal static int[] RetainedIndices(LapReviewPoint[] points, Func? values = null, CancellationToken cancellationToken = default)
+ {
+ if (points.Length <= MaximumPathPoints) return Enumerable.Range(0, points.Length).ToArray();
+ var stride = (int)Math.Ceiling(points.Length / (double)MaximumBuckets);
+ var kept = new List(MaximumPathPoints);
+ for (var first = 0; first < points.Length; first += stride)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var last = Math.Min(points.Length - 1, first + stride - 1);
+ var low = first; var high = first;
+ var valueLow = first; var valueHigh = first;
+ var minimum = double.PositiveInfinity; var maximum = double.NegativeInfinity;
+ for (var i = first; i <= last; i++)
+ {
+ if (!LapReviewTrackBounds.IsFinite(points[i].Position)) continue;
+ if (!LapReviewTrackBounds.IsFinite(points[low].Position) || points[i].Position.Y < points[low].Position.Y) low = i;
+ if (!LapReviewTrackBounds.IsFinite(points[high].Position) || points[i].Position.Y > points[high].Position.Y) high = i;
+ if (values?.Invoke(i) is { } value && double.IsFinite(value))
+ {
+ if (value < minimum) { valueLow = i; minimum = value; }
+ if (value > maximum) { valueHigh = i; maximum = value; }
+ }
+ }
+ foreach (var index in new[] { first, low, high, valueLow, valueHigh, last }.Distinct().Order()) kept.Add(index);
+ }
+ return kept.ToArray();
+ }
+
+ internal static IEnumerable<(int From, int To)> Segments(LapReviewPoint[] points, int[] retained)
+ {
+ for (var i = 1; i < retained.Length; i++)
+ {
+ var from = retained[i - 1]; var to = retained[i];
+ var valid = LapReviewTrackBounds.IsFinite(points[from].Position);
+ for (var j = from + 1; valid && j <= to; j++)
+ valid = !points[j].BreakBefore && LapReviewTrackBounds.IsFinite(points[j].Position);
+ if (valid) yield return (from, to);
+ }
+ }
+
+ internal static LapReviewTrackScene Build(LapReviewPlotData data, bool showContacts, CancellationToken cancellationToken)
+ {
+ var bounds = LapReviewTrackBounds.From(data);
+ var fit = LapReviewTrackFit.From(data, bounds);
+ var model = new Model3DGroup();
+ if (data.Lap is not { Points.Length: > 0 } lap) { model.Freeze(); return new(model, bounds, fit, 0); }
+ var floor = (bounds.MinimumHeight - bounds.Center.Y) / bounds.Span - .025;
+ var colors = Enumerable.Range(0, 65).Select(_ => new MeshBuilder()).ToArray();
+ var shadow = new MeshBuilder(); var posts = new MeshBuilder(); var referenceMesh = new MeshBuilder();
+ var points = lap.Points; var retained = RetainedIndices(points, i => LapReviewPlot.Value(points[i], data, i), cancellationToken);
+ var range = LapReviewColorRange.From(data);
+ var segments = Segments(points, retained).ToArray();
+ var frames = Frames(points, segments, bounds);
+ var pathLength = segments.Sum(segment => (bounds.Normalize(points[segment.To].Position) - bounds.Normalize(points[segment.From].Position)).Length);
+ var postSpacing = Math.Max(.0001, pathLength / 100);
+ var nextPost = postSpacing / 2; var traveled = 0d;
+ var segmentCount = 0;
+ foreach (var (from, to) in segments)
+ {
+ if ((segmentCount++ & 255) == 0) cancellationToken.ThrowIfCancellationRequested();
+ var a = bounds.Normalize(points[from].Position); var b = bounds.Normalize(points[to].Position);
+ var value = LapReviewPlot.Value(points[to], data, to);
+ var color = value is { } finite && double.IsFinite(finite) ? Math.Clamp((int)Math.Round(range.Fraction(finite) * 63), 0, 63) : 64;
+ var selected = to >= data.SectionStart && from <= data.SectionEnd;
+ colors[color].Tube(a, b, selected ? .0034 : .0024, frames[from].Direction, frames[to].Direction,
+ frames[from].Connections == 1, frames[to].Connections == 1);
+ shadow.Tube(new(a.X, floor, a.Z), new(b.X, floor, b.Z), .0017);
+ var length = (b - a).Length;
+ while (nextPost < traveled + length)
+ {
+ var point = a + (b - a) * ((nextPost - traveled) / length);
+ posts.Tube(new(point.X, floor, point.Z), point, .00038);
+ nextPost += postSpacing;
+ }
+ traveled += length;
+ }
+ Add(model, shadow, Brush(26, 32, 42));
+ Add(model, posts, Brush(40, 48, 59));
+ if (data.Comparison?.CanCompare == true && data.Reference is { } reference)
+ {
+ var referenceCount = 0;
+ var referenceSegments = Segments(reference.Points, RetainedIndices(reference.Points, cancellationToken: cancellationToken)).ToArray();
+ var referenceFrames = Frames(reference.Points, referenceSegments, bounds);
+ foreach (var (from, to) in referenceSegments)
+ {
+ if ((referenceCount++ & 255) == 0) cancellationToken.ThrowIfCancellationRequested();
+ referenceMesh.Tube(bounds.Normalize(reference.Points[from].Position), bounds.Normalize(reference.Points[to].Position), .0016,
+ referenceFrames[from].Direction, referenceFrames[to].Direction, referenceFrames[from].Connections == 1, referenceFrames[to].Connections == 1);
+ }
+ Add(model, referenceMesh, RunComparisonColors.RunB);
+ }
+ for (var i = 0; i < colors.Length; i++) Add(model, colors[i], i == 64 ? Brush(139, 146, 159) : LapReviewPalette.GetBrush(i / 63d));
+ var start = new MeshBuilder();
+ if (LapReviewTrackBounds.IsFinite(points[0].Position))
+ {
+ var position = bounds.Normalize(points[0].Position);
+ for (var i = 0; i < 32; i++)
+ {
+ var a = i * Math.PI / 16; var b = (i + 1) * Math.PI / 16;
+ start.Tube(position + new Vector3D(Math.Cos(a) * .012, 0, Math.Sin(a) * .012),
+ position + new Vector3D(Math.Cos(b) * .012, 0, Math.Sin(b) * .012), .002);
+ }
+ }
+ Add(model, start, LapReviewPalette.StartBrush);
+ if (showContacts && data.Contacts is { } contacts)
+ {
+ var markers = new MeshBuilder();
+ foreach (var contact in contacts)
+ if ((uint)contact.PointIndex < (uint)points.Length && LapReviewTrackBounds.IsFinite(points[contact.PointIndex].Position))
+ markers.Diamond(bounds.Normalize(points[contact.PointIndex].Position), .009);
+ Add(model, markers, LapReviewPalette.ContactBrush);
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ model.Freeze();
+ return new(model, bounds, fit, segmentCount);
+ }
+
+ private readonly record struct PathFrame(Vector3D Direction, int Connections);
+ private static Dictionary Frames(LapReviewPoint[] points, (int From, int To)[] segments, LapReviewTrackBounds bounds)
+ {
+ var frames = new Dictionary();
+ foreach (var (from, to) in segments)
+ {
+ var direction = bounds.Normalize(points[to].Position) - bounds.Normalize(points[from].Position);
+ if (direction.LengthSquared > 1e-18) direction.Normalize();
+ foreach (var index in new[] { from, to })
+ {
+ frames.TryGetValue(index, out var previous);
+ frames[index] = new(previous.Direction + direction, previous.Connections + 1);
+ }
+ }
+ return frames;
+ }
+
+ internal static GeometryModel3D CursorModel()
+ {
+ var marker = new MeshBuilder(); marker.Diamond(new(), .008);
+ var mesh = marker.Finish(); var material = new DiffuseMaterial(Brushes.White); material.Freeze();
+ var result = new GeometryModel3D(mesh, material) { BackMaterial = material };
+ result.Freeze(); return result;
+ }
+
+ private static Brush Brush(byte r, byte g, byte b) { var brush = new SolidColorBrush(Color.FromRgb(r, g, b)); brush.Freeze(); return brush; }
+ private static void Add(Model3DGroup group, MeshBuilder builder, Brush brush)
+ {
+ if (builder.Count == 0) return;
+ // Emissive materials add overlapping tube faces without writing depth.
+ // Opaque diffuse + the viewport's white ambient light keeps palette colors exact.
+ var material = new DiffuseMaterial(brush); material.Freeze();
+ var model = new GeometryModel3D(builder.Finish(), material) { BackMaterial = material }; model.Freeze();
+ group.Children.Add(model);
+ }
+
+ private sealed class MeshBuilder
+ {
+ private readonly List _positions = [];
+ private readonly List _triangles = [];
+ internal int Count => _positions.Count;
+ internal void Tube(Point3D a, Point3D b, double radius, Vector3D? startDirection = null,
+ Vector3D? endDirection = null, bool capStart = false, bool capEnd = false)
+ {
+ var direction = b - a;
+ if (direction.LengthSquared < 1e-18) return;
+ direction.Normalize();
+ (Vector3D U, Vector3D V) Basis(Vector3D? tangent)
+ {
+ var axis = tangent is { LengthSquared: > 1e-18 } given ? given : direction;
+ axis.Normalize();
+ var u = Vector3D.CrossProduct(axis, Math.Abs(axis.Y) < .9 ? new(0, 1, 0) : new(1, 0, 0));
+ u.Normalize(); u *= radius;
+ return (u, Vector3D.CrossProduct(axis, u));
+ }
+ var firstBasis = Basis(startDirection); var lastBasis = Basis(endDirection);
+ var offset = _positions.Count;
+ const int sides = 8;
+ for (var side = 0; side < sides; side++)
+ {
+ var angle = side * Math.PI * 2 / sides;
+ _positions.Add(a + firstBasis.U * Math.Cos(angle) + firstBasis.V * Math.Sin(angle));
+ _positions.Add(b + lastBasis.U * Math.Cos(angle) + lastBasis.V * Math.Sin(angle));
+ }
+ for (var side = 0; side < sides; side++)
+ {
+ var first = offset + side * 2; var next = offset + (side + 1) % sides * 2;
+ _triangles.AddRange([first, next, first + 1, first + 1, next, next + 1]);
+ }
+ if (capStart)
+ {
+ var center = _positions.Count; _positions.Add(a);
+ for (var side = 0; side < sides; side++) _triangles.AddRange([center, offset + (side + 1) % sides * 2, offset + side * 2]);
+ }
+ if (capEnd)
+ {
+ var center = _positions.Count; _positions.Add(b);
+ for (var side = 0; side < sides; side++) _triangles.AddRange([center, offset + side * 2 + 1, offset + (side + 1) % sides * 2 + 1]);
+ }
+ }
+ internal void Diamond(Point3D p, double radius)
+ {
+ var offset = _positions.Count;
+ _positions.AddRange([p + new Vector3D(radius, 0, 0), p + new Vector3D(-radius, 0, 0),
+ p + new Vector3D(0, radius, 0), p + new Vector3D(0, -radius, 0),
+ p + new Vector3D(0, 0, radius), p + new Vector3D(0, 0, -radius)]);
+ foreach (var i in new[] { 0, 2, 4, 4, 2, 1, 1, 2, 5, 5, 2, 0, 0, 4, 3, 4, 1, 3, 1, 5, 3, 5, 0, 3 }) _triangles.Add(offset + i);
+ }
+ internal MeshGeometry3D Finish()
+ {
+ var mesh = new MeshGeometry3D { Positions = new Point3DCollection(_positions), TriangleIndices = new Int32Collection(_triangles) };
+ mesh.Freeze(); return mesh;
+ }
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index 99d3d3e..f1cf186 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -4,6 +4,31 @@
xmlns:local="clr-namespace:Wisp.App.Runs">
+
+
@@ -69,12 +94,88 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index a0261d1..701ec95 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -1,6 +1,10 @@
using System.Globalization;
+using System.IO;
+using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
namespace Wisp.App.Runs;
@@ -10,9 +14,76 @@ public LapReviewView()
{
InitializeComponent();
Track.PointChosen += Pick;
+ Track3D.PointChosen += Pick;
Trace.PointChosen += Pick;
}
private void Pick(int index) { if (DataContext is LapReviewViewModel model) model.Cursor = index; }
+ private void ResetMapView_Click(object sender, RoutedEventArgs e) => Track3D.ResetView();
+ private void ZoomMapIn_Click(object sender, RoutedEventArgs e) => Track3D.ZoomBy(1.25);
+ private void ZoomMapOut_Click(object sender, RoutedEventArgs e) => Track3D.ZoomBy(.8);
+ private bool _exporting;
+
+ private async void SaveMapPng_Click(object sender, RoutedEventArgs e)
+ {
+ if (_exporting || DataContext is not LapReviewViewModel { HasLap: true } model) return;
+ if (model.Is3D && !Track3D.IsReady) { model.MapStatus = "Wait for the 3D map to finish preparing, then save the image."; return; }
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ Title = "Save the current lap map",
+ Filter = "PNG image (*.png)|*.png",
+ DefaultExt = ".png",
+ FileName = $"wisp-lap-{(model.Is3D ? "3d" : "2d")}-{DateTime.Now:yyyyMMdd-HHmmss}",
+ AddExtension = true,
+ OverwritePrompt = false
+ };
+ dialog.FileOk += (_, cancel) =>
+ {
+ if (Path.GetExtension(dialog.FileName).Equals(".png", StringComparison.OrdinalIgnoreCase) && !File.Exists(dialog.FileName)) return;
+ cancel.Cancel = true;
+ MessageBox.Show(Window.GetWindow(this), "Choose a new filename ending in .png to keep existing files.", "Choose a PNG filename", MessageBoxButton.OK, MessageBoxImage.Information);
+ };
+ if (dialog.ShowDialog(Window.GetWindow(this)) != true) return;
+ _exporting = true;
+ SaveMapPng.IsEnabled = false;
+ model.MapStatus = "Saving map image…";
+ try
+ {
+ var bitmap = CaptureMapImage();
+ await RunImageExporter.WriteAsync(bitmap, dialog.FileName);
+ model.MapStatus = "Map image saved.";
+ }
+ catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
+ {
+ model.MapStatus = "The map image could not be saved. Check the destination and try again.";
+ DebugLogging.HealthContextRecorder.Current.RecordBreadcrumb(DebugLogging.HealthEventCode.LapReviewFailed, error.HResult);
+ }
+ finally { _exporting = false; SaveMapPng.ClearValue(IsEnabledProperty); }
+ }
+
+ internal BitmapSource CaptureMapImage()
+ {
+ if (DataContext is not LapReviewViewModel { HasLap: true } model || model.Is3D && !Track3D.IsReady)
+ throw new InvalidOperationException("The lap map is not ready.");
+ MapExportSurface.UpdateLayout();
+ var size = MapExportSurface.RenderSize;
+ if (size.Width <= 0 || size.Height <= 0 || !double.IsFinite(size.Width) || !double.IsFinite(size.Height))
+ throw new InvalidOperationException("The lap map has no layout.");
+ // Bound export memory while keeping small windows readable in shared images.
+ var scale = Math.Min(2, Math.Min(3840 / size.Width, 2160 / size.Height));
+ var bitmap = new RenderTargetBitmap((int)Math.Ceiling(size.Width * scale), (int)Math.Ceiling(size.Height * scale), 96 * scale, 96 * scale, PixelFormats.Pbgra32);
+ // Render in local coordinates; directly rendering a scrolled child also
+ // applies its layout offset and can crop the legend off the saved image.
+ var visual = new DrawingVisual();
+ using (var drawing = visual.RenderOpen())
+ {
+ drawing.DrawRectangle(MapExportSurface.Background, null,
+ new Rect(0, 0, bitmap.PixelWidth / scale, bitmap.PixelHeight / scale));
+ var brush = new VisualBrush(MapExportSurface) { Stretch = Stretch.Fill };
+ drawing.DrawRectangle(brush, null, new Rect(size));
+ }
+ bitmap.Render(visual); bitmap.Freeze();
+ return bitmap;
+ }
}
public sealed class LapReviewMapHeightConverter : IValueConverter
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs b/src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs
new file mode 100644
index 0000000..7a47e9e
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.ContactAnalysis.cs
@@ -0,0 +1,29 @@
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewViewModel
+{
+ private LapReviewLap? _telemetryContactLap;
+ private LapReviewContact[] _telemetryContacts = [];
+
+ private LapReviewContact[]? PreparedTelemetryContacts(LapReviewLap lap) =>
+ ReferenceEquals(_telemetryContactLap, lap) ? _telemetryContacts : null;
+
+ private LapReviewContact[] CachedTelemetryContacts(LapReviewLap lap) =>
+ PreparedTelemetryContacts(lap) ?? [];
+
+ private void AdoptTelemetryContacts(LapReviewLap lap, LapReviewContact[] contacts)
+ {
+ if (ReferenceEquals(_telemetryContactLap, lap) && ReferenceEquals(_telemetryContacts, contacts)) return;
+ _telemetryContactLap = lap;
+ _telemetryContacts = contacts;
+ _contactLap = null;
+ }
+
+ private void ClearTelemetryContacts()
+ {
+ _telemetryContactLap = null;
+ _telemetryContacts = [];
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
new file mode 100644
index 0000000..aff1b97
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -0,0 +1,143 @@
+using System.Windows.Input;
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed record LapContactChoice(int PointIndex, string Label);
+
+public sealed partial class LapReviewViewModel
+{
+ private bool _is3D, _showContacts = true, _savingContact, _contactClosing;
+ private Task _contactSaveTask = Task.CompletedTask;
+ private LapReviewLap? _contactLap;
+ private RunMarker[]? _contactMarkers;
+ private LapReviewContact[] _contacts = [];
+ private LapReviewPlotData? _legendData;
+ private LapContactChoice? _selectedContact;
+ private string _mapStatus = "";
+ public bool Is3D { get => _is3D; set { if (Set(ref _is3D, value)) Changed(nameof(Is2D)); } }
+ public bool Is2D { get => !Is3D; set { if (value) Is3D = false; } }
+ public bool ShowContacts { get => _showContacts; set { if (Set(ref _showContacts, value)) Changed(nameof(Plot)); } }
+ public string MapStatus { get => _mapStatus; set => Set(ref _mapStatus, value); }
+ public string LegendMinimum { get; private set; } = "—";
+ public string LegendMaximum { get; private set; } = "—";
+ public string LegendTitle { get; private set; } = "Speed";
+ public string MapTitle => Lap is { } lap ? $"{lap.RunName} · {lap.Label}" : "Recorded lap";
+ public string MapCursorValue => Lap is { Points.Length: > 0 } lap ?
+ $"{Channel.Label}: {RunPresentation.Number(LapReviewPlot.Value(lap.Points[Cursor], Plot, Cursor), " " + LapReviewPlot.Unit(Plot))}" : "";
+ public string ElevationSummary { get; private set; } = "";
+ public bool HasMapReference => _comparison?.CanCompare == true && Reference is not null;
+ public string ContactSummary => _contacts.Length == 0 ? "No contact markers" : $"{_contacts.Length} contact marker{(_contacts.Length == 1 ? "" : "s")}";
+ public IReadOnlyList ContactChoices { get; private set; } = [];
+ public LapContactChoice? SelectedContact
+ {
+ get => _selectedContact;
+ set { if (Set(ref _selectedContact, value) && value is not null) Cursor = value.PointIndex; }
+ }
+ public bool CanMarkContact => HasLap && !_disposed && !_savingContact && !_contactClosing && !IsBusy;
+ public bool CanRemoveContact => CanMarkContact && _contacts.Any(c => c.PointIndex == Cursor && c.Kind == LapReviewContactKind.UserMarkedContact);
+ public ICommand MarkContactCommand { get; private set; } = null!;
+ public ICommand RemoveContactCommand { get; private set; } = null!;
+ public event Action? ContactMarkersChanged;
+
+ private IReadOnlyList VisibleContacts
+ {
+ get
+ {
+ EnsureContacts();
+ return ShowContacts ? _contacts : Array.Empty();
+ }
+ }
+
+ private void InitializeMap()
+ {
+ MarkContactCommand = new RunUiCommand(() => SetContactAsync(true), () => CanMarkContact,
+ () => MapStatus = "The contact marker could not be saved. Try again.");
+ RemoveContactCommand = new RunUiCommand(() => SetContactAsync(false), () => CanRemoveContact,
+ () => MapStatus = "The contact marker could not be removed. Try again.");
+ _commands.Add((RunUiCommand)MarkContactCommand); _commands.Add((RunUiCommand)RemoveContactCommand);
+ }
+
+ private void EnsureContacts()
+ {
+ var markers = _run?.Markers;
+ if (ReferenceEquals(_contactLap, Lap) && ReferenceEquals(_contactMarkers, markers)) return;
+ _contactLap = Lap; _contactMarkers = markers;
+ _contacts = Lap is null ? [] : CachedTelemetryContacts(Lap).Concat(
+ LapReviewContacts.FromMarkers(Lap, markers ?? [])).OrderBy(c => c.PointIndex).ToArray();
+ ContactChoices = _contacts.Select(c => new LapContactChoice(c.PointIndex,
+ $"{c.Label} · {c.DistanceMeters:0} m · {Lap!.Points[c.PointIndex].LapSeconds:0.000} s")).ToArray();
+ _selectedContact = null;
+ Changed(nameof(ContactChoices)); Changed(nameof(SelectedContact)); Changed(nameof(ContactSummary));
+ }
+
+ private Task SetContactAsync(bool marked)
+ {
+ if (!CanMarkContact || Lap is not { } lap || _run is not { } run) return Task.CompletedTask;
+ var seconds = marked ? lap.Points[Cursor].RunSeconds :
+ _contacts.First(c => c.PointIndex == Cursor && c.Kind == LapReviewContactKind.UserMarkedContact).RunSeconds;
+ _savingContact = true;
+ _contactSaveTask = SaveContactCoreAsync(run, seconds, marked);
+ NotifyContactAvailability();
+ return _contactSaveTask;
+ }
+
+ private async Task SaveContactCoreAsync(RecordedRun run, double seconds, bool marked)
+ {
+ // Establish the owning task before notifications can reenter close preparation.
+ await Task.Yield();
+ MapStatus = "Saving contact marker…";
+ try
+ {
+ var markers = await _store.SetContactMarkerAsync(run.Id, seconds, marked);
+ if (_disposed) return;
+ if (_run?.Id == run.Id) _run = _run with { Markers = markers };
+ ContactMarkersChanged?.Invoke(run.Id, markers);
+ MapStatus = marked ? "Contact marker saved with the run." : "Contact marker removed.";
+ Changed(nameof(Plot));
+ }
+ finally { _savingContact = false; if (!_disposed) NotifyContactAvailability(); }
+ }
+
+ internal Task PrepareToCloseContactsAsync()
+ {
+ _contactClosing = true;
+ NotifyContactAvailability();
+ // An earlier reported failure is not a pending edit. A failure during
+ // this close attempt propagates so the normal shutdown guard keeps Wisp open.
+ return _savingContact ? _contactSaveTask : Task.CompletedTask;
+ }
+
+ internal void CancelContactClosePreparation()
+ {
+ _contactClosing = false;
+ if (!_disposed) NotifyContactAvailability();
+ }
+
+ private void NotifyContactAvailability()
+ {
+ RaiseCommands(); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
+ }
+
+ private void RefreshMapDetails()
+ {
+ var data = Plot;
+ EnsureContacts();
+ if (_legendData is not { } old || !ReferenceEquals(old.Lap, data.Lap) || !ReferenceEquals(old.Comparison, data.Comparison) ||
+ old.Channel != data.Channel || old.Wheel != data.Wheel || old.SpeedUnit != data.SpeedUnit ||
+ old.TemperatureUnit != data.TemperatureUnit || old.TorqueUnit != data.TorqueUnit)
+ {
+ _legendData = data;
+ var range = LapReviewColorRange.From(data);
+ LegendTitle = Channel.Label + (IsWheelChannel ? " · " + Wheels[SelectedWheel] : "");
+ LegendMinimum = range.HasValues ? $"{range.Minimum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
+ LegendMaximum = range.HasValues ? $"{range.Maximum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
+ ElevationSummary = data.Lap is { Points.Length: > 0 } lap ?
+ $"Recorded elevation: {lap.Points.Min(p => p.Position.Y):0.0}–{lap.Points.Max(p => p.Position.Y):0.0} m · true scale" : "";
+ foreach (var name in new[] { nameof(LegendMinimum), nameof(LegendMaximum), nameof(LegendTitle), nameof(ElevationSummary), nameof(MapTitle) }) Changed(name);
+ }
+ Changed(nameof(MapCursorValue)); Changed(nameof(HasMapReference)); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
+ if (MarkContactCommand is RunUiCommand mark) mark.RaiseCanExecuteChanged();
+ if (RemoveContactCommand is RunUiCommand remove) remove.RaiseCanExecuteChanged();
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.cs b/src/Wisp.App/Runs/LapReviewViewModel.cs
index b8b3b13..967fb86 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.cs
@@ -15,9 +15,9 @@ public sealed record LapReviewWheelReadout(string Label, string FrontLeft, strin
public sealed record LapReviewCursorReadout(string Position, string Speed, string Powertrain, string Throttle, string Brake,
string Steering, string LateralG, string LongitudinalG, string Delta, LapReviewWheelReadout Temperatures,
LapReviewWheelReadout SlipRatio, LapReviewWheelReadout SlipAngle, LapReviewWheelReadout Suspension);
-public enum LapReviewChannel { Speed, Delta, Throttle, Brake, Steering, LateralG, LongitudinalG, CombinedG, Rpm, Gear, TireTemperature, SlipRatio, SlipAngle, Suspension }
+public enum LapReviewChannel { Speed, Delta, Throttle, Brake, Steering, LateralG, LongitudinalG, CombinedG, Rpm, Gear, TireTemperature, SlipRatio, SlipAngle, Suspension, Power, Torque, Elevation }
-public sealed class LapReviewViewModel : INotifyPropertyChanged, IDisposable
+public sealed partial class LapReviewViewModel : INotifyPropertyChanged, IDisposable
{
private readonly AppSettings _settings;
private readonly RunStore _store;
@@ -46,7 +46,7 @@ public sealed class LapReviewViewModel : INotifyPropertyChanged, IDisposable
public ObservableCollection Metrics { get; } = [];
public ObservableCollection Events { get; } = [];
public LapTimingChoice[] TimingChoices { get; } = [new("Race / Rivals laps", LapTimingMode.GameLaps), new("Time Attack", LapTimingMode.TimeAttack)];
- public LapChannelChoice[] Channels { get; } = [new("Speed", LapReviewChannel.Speed), new("Time delta", LapReviewChannel.Delta), new("Throttle", LapReviewChannel.Throttle), new("Brake", LapReviewChannel.Brake), new("Steering input", LapReviewChannel.Steering), new("Lateral G", LapReviewChannel.LateralG), new("Longitudinal G", LapReviewChannel.LongitudinalG), new("Combined G", LapReviewChannel.CombinedG), new("Engine RPM", LapReviewChannel.Rpm), new("Gear", LapReviewChannel.Gear), new("Tire temperature", LapReviewChannel.TireTemperature), new("Tire slip ratio", LapReviewChannel.SlipRatio), new("Tire slip angle (raw)", LapReviewChannel.SlipAngle), new("Suspension travel", LapReviewChannel.Suspension)];
+ public LapChannelChoice[] Channels { get; } = [new("Speed", LapReviewChannel.Speed), new("Time delta", LapReviewChannel.Delta), new("Throttle", LapReviewChannel.Throttle), new("Brake", LapReviewChannel.Brake), new("Steering input", LapReviewChannel.Steering), new("Lateral G", LapReviewChannel.LateralG), new("Longitudinal G", LapReviewChannel.LongitudinalG), new("Combined G", LapReviewChannel.CombinedG), new("Engine RPM", LapReviewChannel.Rpm), new("Gear", LapReviewChannel.Gear), new("Tire temperature", LapReviewChannel.TireTemperature), new("Tire slip ratio", LapReviewChannel.SlipRatio), new("Tire slip angle (raw)", LapReviewChannel.SlipAngle), new("Suspension travel", LapReviewChannel.Suspension), new("Horsepower", LapReviewChannel.Power), new("Torque", LapReviewChannel.Torque), new("Elevation", LapReviewChannel.Elevation)];
public ICommand SectionStartCommand { get; }
public ICommand SectionEndCommand { get; }
public ICommand WholeLapCommand { get; }
@@ -65,6 +65,7 @@ public LapReviewViewModel(AppSettings settings, RunStore store, Action save)
UnpinCommand = Command(() => { _settings.LapReviewBenchmarkRunId = null; _save(); _ = LoadAsync(); }, () => _settings.LapReviewBenchmarkRunId is not null);
ShowGraphsCommand = Command(() => { if (Lap is { Points.Length: > 0 }) SectionChosen?.Invoke(Lap.Points[_sectionStart].RunSeconds, Lap.Points[_sectionEnd].RunSeconds); },
() => HasLap && _sectionEnd > _sectionStart && Lap!.Points[_sectionEnd].RunSeconds > Lap.Points[_sectionStart].RunSeconds);
+ InitializeMap();
}
private RunUiCommand Command(Action action, Func? canExecute = null)
{
@@ -108,7 +109,7 @@ public LapReviewLap? Lap
public LapReviewCursorReadout? CursorDetails { get; private set; }
public string CursorText => CursorDetails is { } details ? $"{details.Position} · {details.Speed} · {details.Powertrain}" : "";
public string SectionText { get; private set; } = "";
- public LapReviewPlotData Plot => new(Lap, Reference, _comparison, Channel.Channel, _settings.SpeedUnit, Cursor, _sectionStart, _sectionEnd, SelectedWheel, _settings.TireTemperatureUnit);
+ public LapReviewPlotData Plot => new(Lap, Reference, _comparison, Channel.Channel, _settings.SpeedUnit, Cursor, _sectionStart, _sectionEnd, SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, VisibleContacts);
public void RefreshSettings() { if (_disposed) return; Changed(nameof(AutomaticRecording)); RefreshCursor(); FormatSection(); Changed(nameof(Plot)); RaiseCommands(); }
public void SetRuns(RecordedRun? run, RecordedRun? comparison)
{
@@ -214,6 +215,7 @@ private void ClearReview()
try
{
_lap = _reference = null; _comparison = null;
+ ClearTelemetryContacts();
_cursor = _sectionStart = _sectionEnd = 0;
Laps.Clear(); ReferenceLaps.Clear();
_sectionStatistics = _referenceStatistics = null; _referenceSectionSeconds = null;
@@ -242,8 +244,10 @@ private async Task AnalyzeAsync(bool compare)
if (lap is not { Points.Length: > 1 }) return;
_sectionStart = Math.Clamp(_sectionStart, 0, MaximumCursor); _sectionEnd = Math.Clamp(_sectionEnd, _sectionStart, MaximumCursor);
var first = _sectionStart; var last = _sectionEnd;
+ var cachedContacts = PreparedTelemetryContacts(lap);
var result = await Task.Run(() =>
{
+ var contacts = cachedContacts ?? LapReviewContacts.Find(lap, cancel.Token);
if (compare) comparison = reference is null ? null : LapReviewAnalysis.Compare(lap, reference, cancel.Token);
var stats = LapReviewAnalysis.AnalyzeSection(lap, first, last, cancel.Token);
LapSectionStatistics? other = null;
@@ -257,12 +261,13 @@ private async Task AnalyzeAsync(bool compare)
if (from.ReferenceLapSeconds is { } start && to.ReferenceLapSeconds is { } end && end >= start)
referenceSeconds = end - start;
}
- return (comparison, stats, other, referenceSeconds);
+ return (comparison, stats, other, referenceSeconds, contacts);
}, cancel.Token);
if (_disposed || cancel.IsCancellationRequested || !ReferenceEquals(_analysisCancellation, cancel) ||
!ReferenceEquals(lap, Lap) || !ReferenceEquals(reference, Reference)) return;
_comparison = result.comparison; _sectionStatistics = result.stats;
_referenceStatistics = result.other; _referenceSectionSeconds = result.referenceSeconds;
+ AdoptTelemetryContacts(lap, result.contacts);
FormatSection(); RefreshCursor(); Changed(nameof(Plot));
}
catch (OperationCanceledException) { }
@@ -338,6 +343,10 @@ private static LapReviewWheelReadout WheelReadout(string label, WheelValues whee
}
private static string Summaries(LapWheelSummaries wheels, Func format) => string.Join(" / ", new[] { wheels.FrontLeft, wheels.FrontRight, wheels.RearLeft, wheels.RearRight }.Select(n => format(n.Mean)));
private bool Set(ref T field, T value, [CallerMemberName] string? name = null) { if (EqualityComparer.Default.Equals(field, value)) return false; field = value; Changed(name); return true; }
- private void Changed([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name));
+ private void Changed([CallerMemberName] string? name = null)
+ {
+ PropertyChanged?.Invoke(this, new(name));
+ if (name == nameof(Plot)) RefreshMapDetails();
+ }
public void Dispose() { _disposed = true; _loadCancellation?.Cancel(); _analysisCancellation?.Cancel(); RaiseCommands(); }
}
diff --git a/src/Wisp.App/Runs/RunRecordingService.cs b/src/Wisp.App/Runs/RunRecordingService.cs
index 7a01c13..bbc8d3e 100644
--- a/src/Wisp.App/Runs/RunRecordingService.cs
+++ b/src/Wisp.App/Runs/RunRecordingService.cs
@@ -227,7 +227,7 @@ public async ValueTask DisposeAsync()
{
StartedAtUtc = session.StartedAtUtc,
Name = $"Run {session.StartedAtUtc.ToLocalTime():MMM d, h:mm tt}",
- SchemaVersion = session.TuneAttachment is null ? RecordedRun.BaseSchemaVersion : RecordedRun.CurrentSchemaVersion,
+ SchemaVersion = RecordedRun.CurrentSchemaVersion,
TuneAttachment = session.TuneAttachment
};
long rejected = 0;
diff --git a/src/Wisp.App/Runs/RunStore.Contacts.cs b/src/Wisp.App/Runs/RunStore.Contacts.cs
new file mode 100644
index 0000000..136fa5d
--- /dev/null
+++ b/src/Wisp.App/Runs/RunStore.Contacts.cs
@@ -0,0 +1,34 @@
+using System.IO;
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class RunStore
+{
+ public Task SetContactMarkerAsync(Guid id, double elapsedSeconds, bool marked) => InBackground(async () =>
+ {
+ var run = await ReadAsync(RunPath(id), id).ConfigureAwait(false);
+ if (!double.IsFinite(elapsedSeconds) || run.Samples.Length == 0 ||
+ elapsedSeconds < run.Samples[0].ElapsedSeconds || elapsedSeconds > run.Samples[^1].ElapsedSeconds)
+ throw new ArgumentOutOfRangeException(nameof(elapsedSeconds), "Choose a recorded position in this run.");
+ var existing = run.Markers.Where(marker => IsContactAt(marker, elapsedSeconds)).ToArray();
+ if (marked && existing.Length > 0 || !marked && existing.Length == 0) return run.Markers;
+ RunMarker[] markers;
+ if (marked)
+ {
+ if (run.Markers.Length >= MaximumMarkers)
+ throw new InvalidDataException("This run has reached its marker limit. Remove a marker before adding another.");
+ markers = run.Markers.Append(new(elapsedSeconds, LapReviewContacts.ContactMarkerLabel))
+ .OrderBy(marker => marker.ElapsedSeconds).ToArray();
+ }
+ else markers = run.Markers.Where(marker => !IsContactAt(marker, elapsedSeconds)).ToArray();
+ // Read and change only the latest saved markers under the store gate. A
+ // stale review window must not overwrite concurrent notes or tune changes.
+ await WriteAsync(run with { Markers = markers }, overwrite: true).ConfigureAwait(false);
+ return markers;
+ });
+
+ private static bool IsContactAt(RunMarker marker, double seconds) =>
+ string.Equals(marker.Label.Trim(), LapReviewContacts.ContactMarkerLabel, StringComparison.OrdinalIgnoreCase) &&
+ Math.Abs(marker.ElapsedSeconds - seconds) <= .0001;
+}
diff --git a/src/Wisp.App/Runs/RunStore.cs b/src/Wisp.App/Runs/RunStore.cs
index cf03986..48e298b 100644
--- a/src/Wisp.App/Runs/RunStore.cs
+++ b/src/Wisp.App/Runs/RunStore.cs
@@ -189,7 +189,14 @@ internal RunJournal CreateJournal(RecordedRun header, RunJournalStartReservation
EnsureDirectory();
EnsureCapacity(header.Id);
if (!_activeJournals.TryAdd(header.Id, 0)) throw new InvalidOperationException("This run is already being recorded.");
- try { return new RunJournal(Path.Combine(_directory, $"{header.Id:N}.partial"), header, () => _activeJournals.TryRemove(header.Id, out _)); }
+ try
+ {
+ if (!ValidSchema(header)) throw new InvalidDataException("The run format is unsupported.");
+ // Older readers must reject the journal before recovering a prefix and
+ // discarding later samples whose contact fields they cannot deserialize.
+ return new RunJournal(Path.Combine(_directory, $"{header.Id:N}.partial"),
+ header with { SchemaVersion = RecordedRun.CurrentSchemaVersion }, () => _activeJournals.TryRemove(header.Id, out _));
+ }
catch { _activeJournals.TryRemove(header.Id, out _); throw; }
}
finally { reservation.Dispose(); }
@@ -274,6 +281,9 @@ private static async Task ReadAsync(string path, Guid? expectedId =
private async Task WriteAsync(RecordedRun run, bool overwrite)
{
+ if ((run.SchemaVersion is RecordedRun.BaseSchemaVersion or RecordedRun.TuneAttachmentSchemaVersion) &&
+ ValidSchema(run) && run.Samples is { } samples && samples.Any(sample => sample?.State is { } state && HasContactData(state)))
+ run = run with { SchemaVersion = RecordedRun.CurrentSchemaVersion };
Validate(run);
if (!overwrite) EnsureCapacity(run.Id);
var destination = RunPath(run.Id);
@@ -407,6 +417,8 @@ run.Markers is null || run.Markers.Length > MaximumMarkers ||
foreach (var sample in run.Samples)
{
ValidateSample(sample, previous);
+ if (run.SchemaVersion < RecordedRun.CurrentSchemaVersion && HasContactData(sample.State))
+ throw new InvalidDataException("Object-contact telemetry requires run format version 3.");
if (sample.State.CarOrdinal != run.Samples[0].State.CarOrdinal || sample.State.Drivetrain != run.Samples[0].State.Drivetrain)
throw new InvalidDataException("A run cannot combine different cars or drivetrains.");
previous = sample;
@@ -429,10 +441,14 @@ run.Markers is null || run.Markers.Length > MaximumMarkers ||
private static bool ValidSchema(RecordedRun run) => run.SchemaVersion switch
{
RecordedRun.BaseSchemaVersion => run.TuneAttachment is null,
- RecordedRun.CurrentSchemaVersion => run.TuneAttachment is { IsValid: true },
+ RecordedRun.TuneAttachmentSchemaVersion => run.TuneAttachment is { IsValid: true },
+ RecordedRun.CurrentSchemaVersion => run.TuneAttachment is null or { IsValid: true },
_ => false
};
+ private static bool HasContactData(VehicleState state) =>
+ state.SmashableVelocityLossMetersPerSecond.HasValue || state.SmashableMassKilograms.HasValue;
+
private static void ValidateMarker(RunMarker marker, RunMarker? previous, double duration)
{
if (marker is null || !double.IsFinite(marker.ElapsedSeconds) || marker.ElapsedSeconds < 0 || marker.ElapsedSeconds > duration ||
@@ -451,6 +467,9 @@ internal static void ValidateSample(RunSample sample, RunSample? previous)
(state.LocalVelocityXMetersPerSecond is { } localX && !FiniteBound(localX, 500)) ||
(state.LocalVelocityYMetersPerSecond is { } localY && !FiniteBound(localY, 500)) ||
(state.LocalVelocityZMetersPerSecond is { } localZ && !FiniteBound(localZ, 500)) ||
+ state.SmashableVelocityLossMetersPerSecond.HasValue != state.SmashableMassKilograms.HasValue ||
+ (state.SmashableVelocityLossMetersPerSecond is { } impactLoss && (!FiniteBound(impactLoss, 500) || impactLoss < 0)) ||
+ (state.SmashableMassKilograms is { } impactMass && (!FiniteBound(impactMass, 10_000_000) || impactMass < 0)) ||
!FiniteBound(state.EngineMaximumRpm, 30_000) || state.EngineMaximumRpm < 0 ||
!FiniteBound(state.PowerWatts, 100_000_000) || !FiniteBound(state.TorqueNm, 10_000_000) || !FiniteBound(state.BoostPressurePsi, 200) ||
!FiniteBound(state.LateralAccelerationMetersPerSecondSquared, 10_000) || !FiniteBound(state.LongitudinalAccelerationMetersPerSecondSquared, 10_000) ||
diff --git a/src/Wisp.App/Runs/RunsViewModel.Laps.cs b/src/Wisp.App/Runs/RunsViewModel.Laps.cs
index e8c7f15..6f13ea8 100644
--- a/src/Wisp.App/Runs/RunsViewModel.Laps.cs
+++ b/src/Wisp.App/Runs/RunsViewModel.Laps.cs
@@ -9,6 +9,12 @@ public sealed partial class RunsViewModel
private void InitializeLapReview()
{
LapReview = new(_settings, _service.Store, () => PreferencesChanged?.Invoke(this, EventArgs.Empty));
+ LapReview.ContactMarkersChanged += (id, markers) =>
+ {
+ if (_runA?.Id == id) _runA = _runA with { Markers = markers };
+ if (_runB?.Id == id) _runB = _runB with { Markers = markers };
+ RefreshMarkers();
+ };
LapReview.CursorMoved += seconds =>
{
if (_lapCursorSync) return;
diff --git a/src/Wisp.App/Runs/RunsViewModel.Metadata.cs b/src/Wisp.App/Runs/RunsViewModel.Metadata.cs
index 0885d93..24ec49a 100644
--- a/src/Wisp.App/Runs/RunsViewModel.Metadata.cs
+++ b/src/Wisp.App/Runs/RunsViewModel.Metadata.cs
@@ -91,6 +91,7 @@ public Task PrepareToCloseMetadataAsync()
{
if (_metadataCloseTask is { IsCompleted: false }) return _metadataCloseTask;
_metadataClosing = true;
+ var contactSave = LapReview.PrepareToCloseContactsAsync();
CancelCountdown("Countdown canceled because Wisp is closing.");
var revision = ++_metadataCloseRevision;
_metadataCloseTask = PrepareAsync();
@@ -104,6 +105,7 @@ async Task PrepareAsync()
await Task.Yield();
try
{
+ await contactSave;
await _metadataRestoreTask;
foreach (var edit in _metadataEdits.Values.Where(edit => edit.Failed && !edit.Deleted)) edit.AttemptedRevision = -1;
await FlushMetadataAsync();
@@ -121,6 +123,7 @@ public void CancelMetadataClosePreparation()
{
_metadataCloseRevision++;
_metadataClosing = false;
+ LapReview.CancelContactClosePreparation();
NotifyMetadataCloseAvailability();
RefreshStatus();
}
diff --git a/src/Wisp.Core/Runs/LapReviewContacts.cs b/src/Wisp.Core/Runs/LapReviewContacts.cs
new file mode 100644
index 0000000..0b55db5
--- /dev/null
+++ b/src/Wisp.Core/Runs/LapReviewContacts.cs
@@ -0,0 +1,152 @@
+using System.Numerics;
+
+namespace Wisp.Core.Runs;
+
+public enum LapReviewContactKind { PossibleContact, UserMarkedContact, SmashableObject }
+
+public sealed record LapReviewContact(int PointIndex, double RunSeconds, double DistanceMeters,
+ LapReviewContactKind Kind)
+{
+ public string Label => Kind switch
+ {
+ LapReviewContactKind.UserMarkedContact => "Marked contact",
+ LapReviewContactKind.SmashableObject => "Object contact",
+ _ => "Possible contact"
+ };
+}
+
+public static class LapReviewContacts
+{
+ public const string ContactMarkerLabel = "Contact";
+ public const string EvidenceNote = "Object contacts are reported by Forza for breakable objects. Possible contacts are estimates that may miss or misidentify contact. Mark contacts yourself where needed.";
+ private const double Gravity = 9.80665;
+ private const double WindowSeconds = .2;
+ private const double CooldownSeconds = 1;
+
+ // Data Out reports breakable-object impulses but has no general collision flag.
+ // Keep reported and inferred evidence distinct. Analyze once, never while drawing.
+ public static LapReviewContact[] Find(LapReviewLap lap, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(lap);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (lap.Points.Length > LapReviewAnalysis.MaximumPoints) return [];
+ var points = lap.Points;
+ var result = new List();
+ for (var index = 1; index < points.Length; index++)
+ {
+ if ((index & 63) == 0) cancellationToken.ThrowIfCancellationRequested();
+ var before = points[index - 1].Sample.State;
+ var state = points[index].Sample.State;
+ // A positive value at the start or after a gap may describe an old hit.
+ // Emit only an observed zero-to-positive impulse; never count a latched
+ // mass or decaying impulse repeatedly as new collisions.
+ if (before.SmashableVelocityLossMetersPerSecond == 0 &&
+ before.SmashableMassKilograms is >= 0 and <= 10_000_000 &&
+ state.SmashableVelocityLossMetersPerSecond is > 0 and <= 500 &&
+ state.SmashableMassKilograms is > 0 and <= 10_000_000 && Continuous(points[index - 1], points[index]))
+ result.Add(new(index, points[index].RunSeconds, points[index].DistanceMeters, LapReviewContactKind.SmashableObject));
+ }
+ var objectTimes = result.Select(contact => contact.RunSeconds).ToArray();
+ var lastContact = double.NegativeInfinity;
+ var lastStrongAcceleration = points.Length > 0 && HorizontalAcceleration(points[0]) >= 6 * Gravity
+ ? points[0].RunSeconds : double.NegativeInfinity;
+ for (var index = 1; index + 1 < points.Length; index++)
+ {
+ if ((index & 63) == 0) cancellationToken.ThrowIfCancellationRequested();
+ var current = points[index];
+ if (HorizontalAcceleration(current) >= 6 * Gravity) lastStrongAcceleration = current.RunSeconds;
+ if (current.RunSeconds - lastContact < CooldownSeconds ||
+ current.RunSeconds - lastStrongAcceleration > WindowSeconds + 1e-7 ||
+ !Continuous(current, points[index + 1]) || !Grounded(current) || !Grounded(points[index + 1])) continue;
+ // A one-packet speed error which immediately recovers is not an impact.
+ if (points[index + 1].Sample.State.GroundSpeedMetersPerSecond > current.Sample.State.GroundSpeedMetersPerSecond + 1.5) continue;
+ var peakAcceleration = HorizontalAcceleration(current);
+ var peakIndex = index;
+ for (var before = index - 1; before >= 0; before--)
+ {
+ var previous = points[before];
+ var elapsed = current.RunSeconds - previous.RunSeconds;
+ if (elapsed > WindowSeconds + 1e-7 || !Continuous(previous, points[before + 1]) || !Grounded(previous)) break;
+ var acceleration = HorizontalAcceleration(previous);
+ if (acceleration > peakAcceleration) { peakAcceleration = acceleration; peakIndex = before; }
+ var speedLoss = previous.Sample.State.GroundSpeedMetersPerSecond - current.Sample.State.GroundSpeedMetersPerSecond;
+ if (elapsed < .02 || previous.Sample.State.GroundSpeedMetersPerSecond < 8 ||
+ speedLoss < 4 || speedLoss / elapsed < 4 * Gravity || peakAcceleration < 6 * Gravity) continue;
+ var contact = points[peakIndex];
+ if (!NearObjectContact(contact.RunSeconds, objectTimes))
+ result.Add(new(peakIndex, contact.RunSeconds, contact.DistanceMeters, LapReviewContactKind.PossibleContact));
+ lastContact = current.RunSeconds;
+ break;
+ }
+ }
+ return result.OrderBy(contact => contact.PointIndex).ToArray();
+ }
+
+ // Existing run markers provide an explicit alternative when telemetry cannot
+ // identify a contact. No new saved-run schema or game-memory field is required.
+ public static LapReviewContact[] FromMarkers(LapReviewLap lap, IReadOnlyList markers,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(lap);
+ ArgumentNullException.ThrowIfNull(markers);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (lap.Points.Length == 0 || lap.Points.Length > LapReviewAnalysis.MaximumPoints) return [];
+ var result = new List();
+ var seen = new HashSet();
+ foreach (var marker in markers)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!string.Equals(marker.Label?.Trim(), ContactMarkerLabel, StringComparison.OrdinalIgnoreCase) ||
+ !double.IsFinite(marker.ElapsedSeconds) || marker.ElapsedSeconds < lap.Points[0].RunSeconds ||
+ marker.ElapsedSeconds > lap.Points[^1].RunSeconds) continue;
+ var low = 0;
+ var high = lap.Points.Length - 1;
+ while (low < high)
+ {
+ var middle = low + (high - low) / 2;
+ if (lap.Points[middle].RunSeconds < marker.ElapsedSeconds) low = middle + 1;
+ else high = middle;
+ }
+ // Do not move a marker across an unrecorded gap to make it fit a lap.
+ if (low > 0 && lap.Points[low].RunSeconds != marker.ElapsedSeconds &&
+ !Continuous(lap.Points[low - 1], lap.Points[low])) continue;
+ if (low > 0 && marker.ElapsedSeconds - lap.Points[low - 1].RunSeconds < lap.Points[low].RunSeconds - marker.ElapsedSeconds) low--;
+ var point = lap.Points[low];
+ if (Math.Abs(point.RunSeconds - marker.ElapsedSeconds) <= .125 && seen.Add(low))
+ result.Add(new(low, marker.ElapsedSeconds, point.DistanceMeters, LapReviewContactKind.UserMarkedContact));
+ }
+ return result.OrderBy(contact => contact.PointIndex).ToArray();
+ }
+
+ private static bool Grounded(LapReviewPoint point) => point.Sample.State.LocalVelocityYMetersPerSecond is { } vertical &&
+ float.IsFinite(vertical) && Math.Abs(vertical) <= 3 && double.IsFinite(HorizontalAcceleration(point));
+
+ private static bool NearObjectContact(double seconds, double[] times)
+ {
+ var index = Array.BinarySearch(times, seconds);
+ if (index >= 0) return true;
+ index = ~index;
+ return index < times.Length && times[index] - seconds <= WindowSeconds ||
+ index > 0 && seconds - times[index - 1] <= WindowSeconds;
+ }
+
+ private static double HorizontalAcceleration(LapReviewPoint point)
+ {
+ var state = point.Sample.State;
+ var lateral = (double)state.LateralAccelerationMetersPerSecondSquared;
+ var longitudinal = (double)state.LongitudinalAccelerationMetersPerSecondSquared;
+ return Math.Sqrt(lateral * lateral + longitudinal * longitudinal);
+ }
+
+ private static bool Continuous(LapReviewPoint previous, LapReviewPoint current)
+ {
+ if (current.BreakBefore || !RunAnalysis.AreContinuous(previous.Sample, current.Sample) ||
+ !double.IsFinite(previous.LapSeconds) || !double.IsFinite(current.LapSeconds) ||
+ current.LapSeconds - previous.LapSeconds is <= 0 or > .25) return false;
+ var elapsed = current.RunSeconds - previous.RunSeconds;
+ if (!double.IsFinite(elapsed) || elapsed <= 0 || elapsed > .25) return false;
+ var distance = Vector3.Distance(previous.Position.ToVector(), current.Position.ToVector());
+ var speed = Math.Max(previous.Sample.State.GroundSpeedMetersPerSecond, current.Sample.State.GroundSpeedMetersPerSecond);
+ return float.IsFinite(distance) && distance <= Math.Max(1, speed * elapsed * 1.5 + .5);
+ }
+}
diff --git a/src/Wisp.Core/Runs/RunModels.cs b/src/Wisp.Core/Runs/RunModels.cs
index 0743b66..94c18c0 100644
--- a/src/Wisp.Core/Runs/RunModels.cs
+++ b/src/Wisp.Core/Runs/RunModels.cs
@@ -17,8 +17,9 @@ public sealed record RunSample
public sealed record RecordedRun
{
public const int BaseSchemaVersion = 1;
- public const int CurrentSchemaVersion = 2;
- // Runs without structured tune data retain the established v1 format.
+ public const int TuneAttachmentSchemaVersion = 2;
+ public const int CurrentSchemaVersion = 3;
+ // Legacy runs keep their format; retained object-contact telemetry requires v3.
public int SchemaVersion { get; init; } = BaseSchemaVersion;
public Guid Id { get; init; } = Guid.NewGuid();
public string Name { get; init; } = "Untitled run";
diff --git a/src/Wisp.Core/VehicleState.cs b/src/Wisp.Core/VehicleState.cs
index a9d6fd5..e72f9be 100644
--- a/src/Wisp.Core/VehicleState.cs
+++ b/src/Wisp.Core/VehicleState.cs
@@ -56,6 +56,10 @@ public sealed record VehicleState
public float? LocalVelocityXMetersPerSecond { get; init; }
public float? LocalVelocityYMetersPerSecond { get; init; }
public float? LocalVelocityZMetersPerSecond { get; init; }
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public float? SmashableVelocityLossMetersPerSecond { get; init; }
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public float? SmashableMassKilograms { get; init; }
public required WheelValues WheelRotationRadiansPerSecond { get; init; }
public required WheelValues TireSlipRatio { get; init; }
public required WheelValues TireSlipAngle { get; init; }
diff --git a/src/Wisp.Telemetry/Fh6PacketLayout.cs b/src/Wisp.Telemetry/Fh6PacketLayout.cs
index 5281e93..1e0ecc0 100644
--- a/src/Wisp.Telemetry/Fh6PacketLayout.cs
+++ b/src/Wisp.Telemetry/Fh6PacketLayout.cs
@@ -24,6 +24,8 @@ public static class Fh6PacketLayout
// The 324-byte Horizon packet inserts 12 Horizon-specific bytes after the
// common 232-byte Sled prefix. The Dash fields therefore begin at 244.
public const int HorizonExtension = 232;
+ public const int SmashableVelocityLoss = 236;
+ public const int SmashableMass = 240;
public const int GroundSpeed = 256;
public const int Position = 244;
public const int LastLap = 300;
diff --git a/src/Wisp.Telemetry/Fh6PacketParser.cs b/src/Wisp.Telemetry/Fh6PacketParser.cs
index 5c95358..e02289e 100644
--- a/src/Wisp.Telemetry/Fh6PacketParser.cs
+++ b/src/Wisp.Telemetry/Fh6PacketParser.cs
@@ -84,6 +84,7 @@ numCylinders is < 0 or > MaximumPlausibleCylinderCount ||
return false;
}
+ var smashable = ReadSmashable(packet);
state = new VehicleState
{
IsRaceOn = raceFlag == 1,
@@ -102,6 +103,8 @@ numCylinders is < 0 or > MaximumPlausibleCylinderCount ||
LocalVelocityXMetersPerSecond = OptionalVelocity(packet, Fh6PacketLayout.LocalVelocityX),
LocalVelocityYMetersPerSecond = OptionalVelocity(packet, Fh6PacketLayout.LocalVelocityY),
LocalVelocityZMetersPerSecond = OptionalVelocity(packet, Fh6PacketLayout.LocalVelocityZ),
+ SmashableVelocityLossMetersPerSecond = smashable.VelocityLoss,
+ SmashableMassKilograms = smashable.Mass,
WheelRotationRadiansPerSecond = wheelRotation,
TireSlipRatio = slipRatio,
TireSlipAngle = slipAngle,
@@ -126,6 +129,16 @@ numCylinders is < 0 or > MaximumPlausibleCylinderCount ||
return float.IsFinite(value) && MathF.Abs(value) <= 500 ? value : null;
}
+ private static (float? VelocityLoss, float? Mass) ReadSmashable(ReadOnlySpan packet)
+ {
+ // FH6's documented Horizon extension reports breakable-object impacts,
+ // not general car/wall collisions. Invalid optional data must not stop HUD telemetry.
+ var loss = ReadSingle(packet, Fh6PacketLayout.SmashableVelocityLoss);
+ var mass = ReadSingle(packet, Fh6PacketLayout.SmashableMass);
+ return float.IsFinite(loss) && loss is >= 0 and <= 500 && float.IsFinite(mass) && mass is >= 0 and <= 10_000_000
+ ? (loss, mass) : (null, null);
+ }
+
private static LapTelemetry? ReadLap(ReadOnlySpan packet)
{
var position = new System.Numerics.Vector3(ReadSingle(packet, Fh6PacketLayout.Position),
diff --git a/tests/Wisp.App.Tests/LapContactShutdownTests.cs b/tests/Wisp.App.Tests/LapContactShutdownTests.cs
new file mode 100644
index 0000000..62b1265
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapContactShutdownTests.cs
@@ -0,0 +1,168 @@
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Windows.Threading;
+using Wisp.App;
+using Wisp.App.Runs;
+using Wisp.Core.Runs;
+using Wisp.Telemetry;
+using Wisp.UiReview;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class LapContactShutdownTests
+{
+ [Fact]
+ public void CloseWaitsForPendingContactAndLatestNotesBeforeApprovingExit() => OnDispatcher(async () =>
+ {
+ await using var fixture = await Fixture.Create();
+ var model = fixture.Model;
+ var review = model.LapReview;
+ review.Cursor = 80;
+ var seconds = review.Lap!.Points[review.Cursor].RunSeconds;
+ model.Notes = "Keep these notes with the contact.";
+ await fixture.Gate.WaitAsync();
+ Task close;
+ try
+ {
+ review.MarkContactCommand.Execute(null);
+ close = model.PrepareToCloseMetadataAsync();
+ Assert.Same(close, model.PrepareToCloseMetadataAsync());
+ Assert.False(review.MarkContactCommand.CanExecute(null));
+ Assert.False(review.RemoveContactCommand.CanExecute(null));
+ await Task.Delay(30);
+ Assert.False(close.IsCompleted);
+ }
+ finally { fixture.Gate.Release(); }
+ Assert.True(await close);
+ var saved = await fixture.Service.Store.LoadAsync(fixture.Run.Id);
+ Assert.Equal("Keep these notes with the contact.", saved.Notes);
+ Assert.Equal(new RunMarker(seconds, "Contact"), Assert.Single(saved.Markers));
+ Assert.Single(model.PlotMarkers);
+ Assert.False(review.MarkContactCommand.CanExecute(null));
+ model.CancelMetadataClosePreparation();
+ Assert.True(review.MarkContactCommand.CanExecute(null));
+ Assert.True(review.RemoveContactCommand.CanExecute(null));
+ });
+
+ [Fact]
+ public void SaveFailureDuringCloseKeepsWispOpenAndContactCanBeRetried() => OnDispatcher(async () =>
+ {
+ await using var fixture = await Fixture.Create();
+ var model = fixture.Model;
+ var review = model.LapReview;
+ review.Cursor = 80;
+ Task close;
+ using (var locked = new FileStream(fixture.RunPath, FileMode.Open, FileAccess.Read, FileShare.None))
+ {
+ await fixture.Gate.WaitAsync();
+ try
+ {
+ review.MarkContactCommand.Execute(null);
+ close = model.PrepareToCloseMetadataAsync();
+ await Task.Delay(30);
+ Assert.False(close.IsCompleted);
+ }
+ finally { fixture.Gate.Release(); }
+ Assert.False(await close);
+ }
+ Assert.Empty((await fixture.Service.Store.LoadAsync(fixture.Run.Id)).Markers);
+ await WaitUntil(() => review.MarkContactCommand.CanExecute(null));
+ Assert.True(model.CanManageLibrary);
+ review.MarkContactCommand.Execute(null);
+ Assert.True(await model.PrepareToCloseMetadataAsync());
+ Assert.Single((await fixture.Service.Store.LoadAsync(fixture.Run.Id)).Markers);
+ });
+
+ [Fact]
+ public void CancellingCloseDoesNotCancelTheContactOrApproveTheOldCloseAttempt() => OnDispatcher(async () =>
+ {
+ await using var fixture = await Fixture.Create();
+ var model = fixture.Model;
+ var review = model.LapReview;
+ review.Cursor = 80;
+ await fixture.Gate.WaitAsync();
+ Task close;
+ try
+ {
+ review.MarkContactCommand.Execute(null);
+ close = model.PrepareToCloseMetadataAsync();
+ model.CancelMetadataClosePreparation();
+ await Task.Delay(30);
+ Assert.False(close.IsCompleted);
+ Assert.False(review.MarkContactCommand.CanExecute(null));
+ }
+ finally { fixture.Gate.Release(); }
+ Assert.False(await close);
+ Assert.Single((await fixture.Service.Store.LoadAsync(fixture.Run.Id)).Markers);
+ await WaitUntil(() => review.MarkContactCommand.CanExecute(null));
+ Assert.True(await model.PrepareToCloseMetadataAsync());
+ });
+
+ private sealed class Fixture : IAsyncDisposable
+ {
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "Wisp.LapContactShutdownTests", Guid.NewGuid().ToString("N"));
+ private readonly TelemetryUdpReceiver _receiver = new();
+ internal RecordedRun Run { get; } = LapReviewFixtures.Create();
+ internal RunRecordingService Service { get; }
+ internal RunsViewModel Model { get; }
+ internal string RunPath => Path.Combine(_directory, $"{Run.Id:N}.wisprun");
+ internal SemaphoreSlim Gate => (SemaphoreSlim)typeof(RunStore).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(Service.Store)!;
+
+ private Fixture()
+ {
+ Service = new RunRecordingService(_receiver, _directory);
+ Model = new RunsViewModel(Service, new AppSettings(), Dispatcher.CurrentDispatcher);
+ }
+ internal static async Task Create()
+ {
+ var fixture = new Fixture();
+ await fixture.Service.Store.SaveAsync(fixture.Run);
+ await fixture.Model.ShowReviewAsync(fixture.Run);
+ await WaitUntil(() => !fixture.Model.IsBusy && !fixture.Model.IsPreparingCharts && !fixture.Model.LapReview.IsBusy);
+ Assert.False(fixture.Model.HasError);
+ Assert.True(fixture.Model.LapReview.HasLap);
+ // Allow the real store's metadata restore queued by ShowReview to finish.
+ await fixture.Service.Store.LoadAsync(fixture.Run.Id);
+ return fixture;
+ }
+ public async ValueTask DisposeAsync()
+ {
+ try { await Model.LapReview.PrepareToCloseContactsAsync(); }
+ finally
+ {
+ Model.Dispose(); await Service.DisposeAsync(); await _receiver.DisposeAsync();
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+ }
+ }
+
+ private static async Task WaitUntil(Func condition)
+ {
+ var elapsed = Stopwatch.StartNew();
+ while (!condition() && elapsed.Elapsed < TimeSpan.FromSeconds(5)) await Task.Delay(10);
+ Assert.True(condition(), "The bounded contact operation did not settle.");
+ }
+ private static void OnDispatcher(Func test)
+ {
+ Exception? failure = null;
+ using var finished = new ManualResetEventSlim();
+ var thread = new Thread(() =>
+ {
+ var dispatcher = Dispatcher.CurrentDispatcher;
+ _ = dispatcher.InvokeAsync(async () =>
+ {
+ try { await test(); }
+ catch (Exception error) { failure = error; }
+ finally { dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); finished.Set(); }
+ });
+ Dispatcher.Run();
+ })
+ { IsBackground = true };
+ thread.SetApartmentState(ApartmentState.STA); thread.Start();
+ Assert.True(finished.Wait(TimeSpan.FromSeconds(20)), "Contact shutdown check exceeded its dispatcher deadline.");
+ if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw();
+ }
+}
diff --git a/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs b/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs
new file mode 100644
index 0000000..7d93662
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs
@@ -0,0 +1,49 @@
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class LapReviewMapChannelsTests
+{
+ [Fact]
+ public void AddedChannelsReadActualPowerTorqueAndElevation()
+ {
+ var data = Data();
+ var point = data.Lap!.Points[0];
+ Assert.Equal(100, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Power }, 0)!.Value, 4);
+ Assert.Equal(250, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Torque }, 0));
+ Assert.Equal(184.390537325, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Torque, TorqueUnit = TorqueUnit.PoundFeet }, 0)!.Value, 6);
+ Assert.Equal(123.5, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Elevation }, 0));
+ }
+
+ [Fact]
+ public void MissingAndConstantChannelsHaveHonestLegendRanges()
+ {
+ var data = Data() with { Channel = LapReviewChannel.Delta };
+ Assert.False(LapReviewColorRange.From(data).HasValues);
+ var constant = LapReviewColorRange.From(data with { Channel = LapReviewChannel.Power });
+ Assert.True(constant.HasValues);
+ Assert.Equal(constant.Minimum, constant.Maximum);
+ Assert.Equal(.5, constant.Fraction(constant.Minimum));
+ }
+
+ [Fact]
+ public void InvalidEngineOutputNeverBecomesAColoredValue()
+ {
+ var data = Data();
+ var point = data.Lap!.Points[0];
+ point = point with { Sample = point.Sample with { State = point.Sample.State with { PowerWatts = float.NaN, TorqueNm = float.PositiveInfinity } } };
+ Assert.Null(LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Power }, 0));
+ Assert.Null(LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Torque }, 0));
+ }
+
+ private static LapReviewPlotData Data()
+ {
+ var state = RunTestData.State() with { PowerWatts = 74569.987f, TorqueNm = 250 };
+ var point = new LapReviewPoint(0, 0, 0, 0, new(0, 123.5f, 0), false, new() { State = state });
+ return new(new() { Points = [point, point with { SampleIndex = 1, RunSeconds = .1 }] }, null, null,
+ LapReviewChannel.Power, SpeedUnit.MilesPerHour, 0, 0, 1);
+ }
+}
diff --git a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
new file mode 100644
index 0000000..a20a246
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
@@ -0,0 +1,174 @@
+using System.Runtime.ExceptionServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class LapReviewTrack3DTests
+{
+ [Fact]
+ public void HiddenViewDoesNotPrepareMeshes() => OnSta(async () =>
+ {
+ var track = Track();
+ Assert.Equal(0, track.SceneBuildCount); Assert.False(track.IsPreparing);
+ Assert.False(track.IsReady);
+ await Task.CompletedTask;
+ });
+
+ [Fact]
+ public void CursorAndCameraUpdatesReuseThePreparedGeometry() => OnSta(async () =>
+ {
+ var track = Track();
+ await track.PrepareForTestAsync();
+ Assert.True(track.IsReady); Assert.Equal(1, track.SceneBuildCount);
+ var original = track.ProjectPoint(1);
+ for (var i = 0; i < 100; i++) track.Data = track.Data! with { Cursor = i % 3 };
+ track.Yaw = 90; track.Pitch = 45; track.Roll = 15; track.ZoomBy(2); track.PanBy(30, 20);
+ Assert.NotEqual(original, track.ProjectPoint(1));
+ Assert.Equal(1, track.SceneBuildCount); Assert.False(track.IsPreparing);
+ track.ResetView();
+ Assert.Equal(original, track.ProjectPoint(1));
+ });
+
+ [Fact]
+ public void RapidChannelReturnCancelsTheStaleBuildAndRestoresTheCachedScene() => OnSta(async () =>
+ {
+ var track = Track(); var original = track.Data!;
+ await track.PrepareForTestAsync();
+ track.Data = original with { Channel = LapReviewChannel.Brake };
+ var pending = track.PrepareForTestAsync();
+ track.Data = original;
+ await pending;
+ Assert.True(track.IsReady); Assert.Equal(1, track.SceneBuildCount);
+ Assert.Equal(2, track.PreparedSegmentCount);
+ });
+
+ [Fact]
+ public void EmptyLapNavigationAndNonNavigationKeysDoNotSelectInvalidPoints() => OnSta(async () =>
+ {
+ var track = new LapReviewTrack3D { Data = Data([]) };
+ var chosen = new List(); track.PointChosen += chosen.Add;
+ foreach (var key in new[] { Key.Left, Key.Down, Key.Right, Key.Up, Key.Home, Key.End, Key.A })
+ Assert.False(SendKey(track, key).Handled);
+ Assert.Empty(chosen);
+ await Task.CompletedTask;
+ });
+
+ [Theory]
+ [InlineData(Key.Left, 0, 0)]
+ [InlineData(Key.Right, 2, 2)]
+ [InlineData(Key.Up, 0, 1)]
+ [InlineData(Key.Down, 2, 1)]
+ [InlineData(Key.Home, 2, 0)]
+ [InlineData(Key.End, 0, 2)]
+ public void KeyboardSelectionMatchesTwoDimensionalMapBehavior(Key key, int cursor, int expected) => OnSta(async () =>
+ {
+ var track = Track(); track.Data = track.Data! with { Cursor = cursor };
+ var chosen = new List(); track.PointChosen += chosen.Add;
+ Assert.True(SendKey(track, key).Handled); Assert.Equal(expected, Assert.Single(chosen));
+ await Task.CompletedTask;
+ });
+
+ [Fact]
+ public void ChannelWheelAndSectionChangesRebuildButClearingDataRemovesTheScene() => OnSta(async () =>
+ {
+ var track = Track(); await track.PrepareForTestAsync();
+ track.Data = track.Data! with { Channel = LapReviewChannel.TireTemperature, Wheel = 2, SectionStart = 1 };
+ await track.PrepareForTestAsync();
+ Assert.Equal(2, track.SceneBuildCount);
+ track.Data = null;
+ Assert.False(track.IsReady); Assert.False(track.IsPreparing);
+ Assert.Equal("No recorded lap positions", track.StatusText);
+ });
+
+ [Fact]
+ public void InvalidCameraValuesCannotPoisonTheProjection() => OnSta(async () =>
+ {
+ var track = Track(); await track.PrepareForTestAsync();
+ track.Yaw = double.NaN; track.Pitch = double.PositiveInfinity; track.Roll = double.NegativeInfinity;
+ track.ZoomFactor = 0; track.PanBy(double.NaN, 3); track.ZoomBy(double.PositiveInfinity);
+ var point = track.ProjectPoint(1);
+ Assert.True(double.IsFinite(point.X)); Assert.True(double.IsFinite(point.Y));
+ Assert.Equal(.2, track.ZoomFactor); Assert.Equal(LapReviewTrackFit.DefaultPitch, track.Pitch);
+ track.Yaw = 180; track.Roll = -180;
+ Assert.Equal(180, track.Yaw); Assert.Equal(-180, track.Roll);
+ });
+
+ [Fact]
+ public void RenderedTubeColorMatchesTheLegendInsteadOfAddingItsFrontAndBackFaces() => OnSta(async () =>
+ {
+ var track = new LapReviewTrack3D { Data = Data([new(-100, 0, 0), new(0, 0, 0), new(100, 0, 0)]) };
+ var surface = new Border { Background = Brushes.Black, Child = track };
+ surface.Measure(new Size(800, 400)); surface.Arrange(new Rect(0, 0, 800, 400));
+ await track.PrepareForTestAsync();
+ track.Yaw = 0; track.Pitch = 90; track.Roll = 0;
+ var bitmap = new RenderTargetBitmap(800, 400, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(surface);
+ var center = track.ProjectPoint(1);
+ var pixel = new byte[4];
+ bitmap.CopyPixels(new Int32Rect((int)Math.Round(center.X), (int)Math.Round(center.Y), 1, 1), pixel, 4, 0);
+ var expected = ((SolidColorBrush)LapReviewPalette.GetBrush(.5)).Color;
+ Assert.InRange(Math.Abs(pixel[0] - expected.B), 0, 2);
+ Assert.InRange(Math.Abs(pixel[1] - expected.G), 0, 2);
+ Assert.InRange(Math.Abs(pixel[2] - expected.R), 0, 2);
+ Assert.Equal(255, pixel[3]);
+ });
+
+ private static LapReviewTrack3D Track()
+ {
+ var track = new LapReviewTrack3D { Data = Data([new(0, 0, 0), new(20, 10, 30), new(60, 15, 10)]) };
+ track.Measure(new Size(800, 400)); track.Arrange(new Rect(0, 0, 800, 400));
+ return track;
+ }
+ private static LapReviewPlotData Data(LapPosition[] positions) => new(new LapReviewLap
+ {
+ Points = positions.Select((position, i) => new LapReviewPoint(i, i, i, i, position, i == 0,
+ new RunSample { State = RunTestData.State() })).ToArray()
+ }, null, null, LapReviewChannel.Speed, SpeedUnit.MilesPerHour, 0, 0, positions.Length - 1);
+
+ private static KeyEventArgs SendKey(LapReviewTrack3D track, Key key)
+ {
+ var input = new KeyEventArgs(Keyboard.PrimaryDevice, new OffscreenSource(track), Environment.TickCount, key)
+ { RoutedEvent = Keyboard.KeyDownEvent };
+ track.RaiseEvent(input); return input;
+ }
+ private sealed class OffscreenSource(Visual visual) : PresentationSource
+ {
+ public override Visual RootVisual { get; set; } = visual;
+ public override bool IsDisposed => false;
+ protected override CompositionTarget GetCompositionTargetCore() => null!;
+ }
+ private static void OnSta(Func action)
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var dispatcher = Dispatcher.CurrentDispatcher;
+ SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(dispatcher));
+ var task = action();
+ if (!task.IsCompleted)
+ {
+ _ = task.ContinueWith(_ => dispatcher.BeginInvokeShutdown(DispatcherPriority.Background), TaskScheduler.Default);
+ Dispatcher.Run();
+ }
+ task.GetAwaiter().GetResult();
+ }
+ catch (Exception exception) { failure = exception; }
+ finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); }
+ })
+ { IsBackground = true };
+ thread.SetApartmentState(ApartmentState.STA); thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "3D map check did not complete.");
+ if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw();
+ }
+}
diff --git a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
new file mode 100644
index 0000000..1653d2c
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
@@ -0,0 +1,176 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Media3D;
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class LapReviewTrackGeometryTests
+{
+ [Fact]
+ public void NormalizationPreservesActualElevationAndUsesTheSameScaleForEveryAxis()
+ {
+ var data = Data([new(1000, 400, -100), new(2000, 500, 400)]);
+ var bounds = LapReviewTrackBounds.From(data);
+ var first = bounds.Normalize(data.Lap!.Points[0].Position);
+ var last = bounds.Normalize(data.Lap.Points[1].Position);
+ Assert.Equal(1, last.X - first.X, 10);
+ Assert.Equal(.1, last.Y - first.Y, 10);
+ Assert.Equal(-.5, last.Z - first.Z, 10);
+ Assert.Equal(1000, bounds.Span);
+ Assert.Equal(400, bounds.MinimumHeight);
+ }
+
+ [Fact]
+ public void OnlyAnAcceptedReferenceCanExpandTheBounds()
+ {
+ var data = Data([new(0, 0, 0), new(10, 1, 0)]);
+ var reference = Data([new(0, 0, 0), new(1000, 100, 0)]).Lap;
+ Assert.Equal(10, LapReviewTrackBounds.From(data with { Reference = reference }).Span);
+ Assert.Equal(1000, LapReviewTrackBounds.From(data with { Reference = reference, Comparison = new([], 1, true, string.Empty) }).Span);
+ }
+
+ [Fact]
+ public void NonFinitePositionsDoNotPoisonBoundsOrCreateABridge()
+ {
+ var data = Data([new(0, 2, 0), new(float.NaN, 10000, 0), new(10, 3, 0), new(20, 4, 0)]);
+ var points = data.Lap!.Points;
+ var bounds = LapReviewTrackBounds.From(data);
+ Assert.Equal(20, bounds.Span);
+ Assert.Equal([(2, 3)], LapReviewTrackGeometry.Segments(points, [0, 1, 2, 3]).ToArray());
+ Assert.Empty(LapReviewTrackGeometry.Segments(points, [0, 3]));
+ }
+
+ [Fact]
+ public void DownsamplingRetainsHeightExtremaAndBothEndsWithinItsFixedBudget()
+ {
+ var points = Data(Enumerable.Range(0, 36000).Select(i => new LapPosition(i, i == 17 ? 900 : i == 23 ? -500 : 0, 0)).ToArray()).Lap!.Points;
+ var retained = LapReviewTrackGeometry.RetainedIndices(points, cancellationToken: TestContext.Current.CancellationToken);
+ Assert.InRange(retained.Length, 2, LapReviewTrackGeometry.MaximumPathPoints);
+ Assert.Equal(0, retained[0]); Assert.Equal(points.Length - 1, retained[^1]);
+ Assert.Contains(17, retained); Assert.Contains(23, retained);
+ Assert.Equal(retained.Distinct().Order(), retained);
+ }
+
+ [Fact]
+ public void BreaksInsideADownsampledSpanAreNeverConnected()
+ {
+ var points = Data(Enumerable.Range(0, 100).Select(i => new LapPosition(i, 0, 0)).ToArray()).Lap!.Points;
+ points[23] = points[23] with { BreakBefore = true };
+ Assert.Equal([(0, 10), (30, 40)], LapReviewTrackGeometry.Segments(points, [0, 10, 30, 40]).ToArray());
+ }
+
+ [Fact]
+ public void DensePathRetainsBriefBrakingAndPowerExtremaAsWellAsElevation()
+ {
+ var points = Data(Enumerable.Range(0, 36000).Select(i => new LapPosition(i, i == 7 ? -50 : i == 9 ? 80 : 0, 0)).ToArray()).Lap!.Points;
+ var retained = LapReviewTrackGeometry.RetainedIndices(points, i => i == 17 ? -200 : i == 23 ? 800 : 0, TestContext.Current.CancellationToken);
+ Assert.InRange(retained.Length, 2, LapReviewTrackGeometry.MaximumPathPoints);
+ Assert.Contains(7, retained); Assert.Contains(9, retained);
+ Assert.Contains(17, retained); Assert.Contains(23, retained);
+ Assert.Equal(0, retained[0]); Assert.Equal(points.Length - 1, retained[^1]);
+ }
+
+ [Theory]
+ [InlineData(0, 90, 0)]
+ [InlineData(25, 35, 0)]
+ [InlineData(150, -90, 120)]
+ [InlineData(-180, 0, -170)]
+ public void OrbitAndRollKeepAnOrthonormalCameraEvenAtThePoles(double yaw, double pitch, double roll)
+ {
+ var frame = LapReviewCameraFrame.From(yaw, pitch, roll);
+ Assert.Equal(1, frame.Right.Length, 10); Assert.Equal(1, frame.Up.Length, 10); Assert.Equal(1, frame.Forward.Length, 10);
+ Assert.Equal(0, Vector3D.DotProduct(frame.Right, frame.Up), 10);
+ Assert.Equal(0, Vector3D.DotProduct(frame.Right, frame.Forward), 10);
+ Assert.Equal(0, Vector3D.DotProduct(frame.Up, frame.Forward), 10);
+ }
+
+ [Fact]
+ public void TopViewKeepsTheExistingMapHandednessAndSideViewShowsActualHeight()
+ {
+ var bounds = LapReviewTrackBounds.From(Data([new(0, 0, 0), new(100, 20, 50)]));
+ var zero = bounds.Normalize(new(0, 0, 0)); var point = bounds.Normalize(new(100, 20, 50));
+ var top = LapReviewCameraFrame.From(0, 90, 0);
+ var a = top.Project(zero, new(), 2, new Size(800, 400)); var b = top.Project(point, new(), 2, new Size(800, 400));
+ Assert.True(b.X > a.X); Assert.True(b.Y < a.Y);
+ var side = LapReviewCameraFrame.From(0, 0, 0);
+ a = side.Project(zero, new(), 2, new Size(800, 400)); b = side.Project(point, new(), 2, new Size(800, 400));
+ Assert.Equal(80, a.Y - b.Y, 10);
+ }
+
+ [Theory]
+ [InlineData(1600, 360)]
+ [InlineData(500, 480)]
+ public void DefaultCameraFitsTheActualLapWithEqualWorldScaleInsteadOfASquarePlaceholder(double width, double height)
+ {
+ var data = Data([new(0, 0, 0), new(400, 10, 100), new(1000, 20, 0)]);
+ var bounds = LapReviewTrackBounds.From(data); var fit = LapReviewTrackFit.From(data, bounds);
+ var frame = LapReviewCameraFrame.From(fit.Yaw, LapReviewTrackFit.DefaultPitch, 0); var cameraWidth = fit.Width(width / height);
+ var floor = (bounds.MinimumHeight - bounds.Center.Y) / bounds.Span - .025;
+ var path = data.Lap!.Points.Select(p => bounds.Normalize(p.Position)).ToArray();
+ var projected = path.Concat(path.Select(p => new Point3D(p.X, floor, p.Z)))
+ .Select(p => frame.Project(p, fit.Target, cameraWidth, new Size(width, height))).ToArray();
+ Assert.All(projected, p => { Assert.InRange(p.X, 0, width); Assert.InRange(p.Y, 0, height); });
+ var occupiedWidth = projected.Max(p => p.X) - projected.Min(p => p.X);
+ var occupiedHeight = projected.Max(p => p.Y) - projected.Min(p => p.Y);
+ Assert.True(occupiedWidth / width > .85 || occupiedHeight / height > .85);
+ }
+
+ [Fact]
+ public void DefaultCameraShowsAnElongatedTrackHorizontallyWithoutChangingElevation()
+ {
+ var positions = Enumerable.Range(0, 361).Select(i =>
+ {
+ var angle = i * Math.PI / 180;
+ return new LapPosition((float)(100 * Math.Cos(angle)), (float)(20 * Math.Cos(angle)), (float)(1000 * Math.Sin(angle)));
+ }).ToArray();
+ var data = Data(positions); var bounds = LapReviewTrackBounds.From(data); var fit = LapReviewTrackFit.From(data, bounds);
+ Assert.InRange(Math.Abs(fit.Yaw), 89, 91);
+ Assert.True(fit.HorizontalSpan > fit.VerticalSpan * 4);
+ Assert.Equal(40, (bounds.Normalize(positions[0]).Y - bounds.Normalize(positions[180]).Y) * bounds.Span, 6);
+ // Stopping in one part of the lap must not bias the automatic orientation.
+ var duplicates = Data(positions.Take(40).Concat(Enumerable.Repeat(positions[39], 2000)).Concat(positions.Skip(40)).ToArray());
+ Assert.Equal(fit.Yaw, LapReviewTrackFit.From(duplicates, LapReviewTrackBounds.From(duplicates)).Yaw, 8);
+ }
+
+ [Fact]
+ public void AdjacentColoredSegmentsShareTheirEntireRingAtABend()
+ {
+ var data = Data([new(0, 0, 0), new(100, 10, 0), new(150, 30, 80)]);
+ var scene = LapReviewTrackGeometry.Build(data, false, TestContext.Current.CancellationToken);
+ var color = ((SolidColorBrush)LapReviewPalette.GetBrush(.5)).Color;
+ var path = scene.Model.Children.OfType().Single(model =>
+ model.Material is DiffuseMaterial { Brush: SolidColorBrush brush } && brush.Color == color);
+ var mesh = Assert.IsType(path.Geometry);
+ Assert.Equal(8, mesh.Positions.GroupBy(p => p).Count(group => group.Count() == 2));
+ }
+
+ [Fact]
+ public void BuiltGeometryIsFrozenAndCancelledPreparationCannotPublishAPartialScene()
+ {
+ var data = Data([new(0, 0, 0), new(100, 25, 50), new(200, 30, 100)]);
+ var scene = LapReviewTrackGeometry.Build(data, true, CancellationToken.None);
+ Assert.True(scene.Model.IsFrozen); Assert.Equal(2, scene.SegmentCount); Assert.NotEmpty(scene.Model.Children);
+ Assert.Throws(() => LapReviewTrackGeometry.Build(data, true, new CancellationToken(true)));
+ }
+
+ [Fact]
+ public void MissingChannelStillDrawsTheRecordedPathAndContactsAreOptional()
+ {
+ var data = Data([new(0, 0, 0), new(100, 10, 50)]) with
+ { Channel = LapReviewChannel.Delta, Contacts = [new(1, 1, 100, LapReviewContactKind.PossibleContact)] };
+ var without = LapReviewTrackGeometry.Build(data, false, CancellationToken.None);
+ var with = LapReviewTrackGeometry.Build(data, true, CancellationToken.None);
+ Assert.Equal(1, without.SegmentCount);
+ Assert.Equal(without.Model.Children.Count + 1, with.Model.Children.Count);
+ }
+
+ private static LapReviewPlotData Data(LapPosition[] positions) => new(new LapReviewLap
+ {
+ Points = positions.Select((position, i) => new LapReviewPoint(i, i, i, i, position, i == 0,
+ new RunSample { State = RunTestData.State() })).ToArray()
+ }, null, null, LapReviewChannel.Speed, SpeedUnit.MilesPerHour, 0, 0, positions.Length - 1);
+}
diff --git a/tests/Wisp.App.Tests/RunStoreContactsTests.cs b/tests/Wisp.App.Tests/RunStoreContactsTests.cs
new file mode 100644
index 0000000..6177216
--- /dev/null
+++ b/tests/Wisp.App.Tests/RunStoreContactsTests.cs
@@ -0,0 +1,243 @@
+using System.IO;
+using System.Text.Json;
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class RunStoreContactsTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "WispRunContactTests", Guid.NewGuid().ToString("N"));
+
+ [Fact]
+ public async Task AddAndRemoveContactPreserveLatestMetadataAndEverySample()
+ {
+ var store = new RunStore(_directory);
+ var run = RunTestData.CreateRun() with { Markers = [new(0, "Apex")] };
+ await store.SaveAsync(run);
+ await store.UpdateMetadataAsync(run.Id, "Edited after opening lap", "Current tune", "New notes");
+ var time = run.Samples[^1].ElapsedSeconds;
+ var markers = await store.SetContactMarkerAsync(run.Id, time, true);
+ Assert.Equal(2, markers.Length);
+ var loaded = await store.LoadAsync(run.Id);
+ Assert.Equal("Edited after opening lap", loaded.Name);
+ Assert.Equal("Current tune", loaded.Tune);
+ Assert.Equal("New notes", loaded.Notes);
+ Assert.Equal(run.Samples, loaded.Samples);
+ Assert.Equal(markers, loaded.Markers);
+ Assert.Equal(run.LapTimingMode, loaded.LapTimingMode);
+ Assert.Equal(run.SchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(run.StartedAtUtc, loaded.StartedAtUtc);
+ Assert.Equal(2, (await store.SetContactMarkerAsync(run.Id, time, true)).Length);
+ Assert.Equal([new RunMarker(0, "Apex")], await store.SetContactMarkerAsync(run.Id, time, false));
+ Assert.Equal(run.Samples, (await store.LoadAsync(run.Id)).Samples);
+ }
+
+ [Fact]
+ public async Task ContactRemovalKeepsOtherMarkersAtTheSameTime()
+ {
+ var store = new RunStore(_directory);
+ var run = RunTestData.CreateRun() with { Markers = [new(0, "Contact"), new(0, "Apex"), new(.1, "Contact")] };
+ await store.SaveAsync(run);
+ Assert.Equal([new RunMarker(0, "Apex"), new(.1, "Contact")], await store.SetContactMarkerAsync(run.Id, 0, false));
+ }
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(-.1)]
+ [InlineData(100000)]
+ public async Task InvalidTimeCannotModifySavedRun(double time)
+ {
+ var store = new RunStore(_directory);
+ var run = RunTestData.CreateRun();
+ await store.SaveAsync(run);
+ await Assert.ThrowsAsync(() => store.SetContactMarkerAsync(run.Id, time, true));
+ Assert.Equal(run.Markers, (await store.LoadAsync(run.Id)).Markers);
+ }
+
+ [Fact]
+ public async Task MarkerLimitDoesNotReplaceOrLoseExistingMarkers()
+ {
+ var store = new RunStore(_directory);
+ var run = RunTestData.CreateRun() with
+ {
+ Markers = Enumerable.Range(0, RunStore.MaximumMarkers).Select(index => new RunMarker(0, $"Note {index}")).ToArray()
+ };
+ await store.SaveAsync(run);
+ await Assert.ThrowsAsync(() => store.SetContactMarkerAsync(run.Id, 0, true));
+ Assert.Equal(run.Markers, (await store.LoadAsync(run.Id)).Markers);
+ Assert.Equal(run.Markers, await store.SetContactMarkerAsync(run.Id, 0, false));
+ }
+
+ [Fact]
+ public async Task ConcurrentEditsReadLatestRunBeforeChangingOnlyTheirOwnedFields()
+ {
+ var store = new RunStore(_directory);
+ var run = RunTestData.CreateRun();
+ await store.SaveAsync(run);
+ await Task.WhenAll(store.UpdateMetadataAsync(run.Id, "Renamed", "Retuned", "Updated"),
+ store.SetContactMarkerAsync(run.Id, 0, true), store.SetContactMarkerAsync(run.Id, run.Samples[^1].ElapsedSeconds, true));
+ var loaded = await store.LoadAsync(run.Id);
+ Assert.Equal("Renamed", loaded.Name);
+ Assert.Equal("Retuned", loaded.Tune);
+ Assert.Equal("Updated", loaded.Notes);
+ Assert.Equal(2, loaded.Markers.Count(marker => marker.Label == "Contact"));
+ Assert.Equal(run.Samples, loaded.Samples);
+ }
+
+ [Fact]
+ public async Task OptionalObjectImpactFieldsRoundTripAndLegacyRunsKeepUnknownRatherThanInventedZero()
+ {
+ var store = new RunStore(_directory);
+ var original = RunTestData.CreateRun();
+ await store.SaveAsync(original);
+ var legacy = await store.LoadAsync(original.Id);
+ Assert.Equal(RecordedRun.BaseSchemaVersion, legacy.SchemaVersion);
+ Assert.All(legacy.Samples, sample =>
+ {
+ Assert.Null(sample.State.SmashableVelocityLossMetersPerSecond);
+ Assert.Null(sample.State.SmashableMassKilograms);
+ var json = System.Text.Json.JsonSerializer.Serialize(sample.State, RunStore.JsonOptions);
+ Assert.DoesNotContain("smashable", json, StringComparison.OrdinalIgnoreCase);
+ });
+ var current = original with
+ {
+ Id = Guid.NewGuid(),
+ Samples = original.Samples.Select(sample => sample with
+ {
+ State = sample.State with { SmashableVelocityLossMetersPerSecond = 2.5f, SmashableMassKilograms = 150 }
+ }).ToArray()
+ };
+ await store.SaveAsync(current);
+ var saved = await store.LoadAsync(current.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, saved.SchemaVersion);
+ Assert.Equal(current.Samples, saved.Samples);
+ var exported = Path.Combine(_directory, "contact-export.wisprun");
+ await store.ExportAsync(current.Id, exported);
+ var importStore = new RunStore(Path.Combine(_directory, "imported"));
+ var imported = await importStore.ImportAsync(exported);
+ var importedRun = await importStore.LoadAsync(imported.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, importedRun.SchemaVersion);
+ Assert.Equal(current.Samples, importedRun.Samples);
+ }
+
+ [Fact]
+ public async Task ContactTelemetryUpgradesStructuredTuneFormatWithoutChangingSnapshot()
+ {
+ var snapshot = TuneUiTestData.ValidSnapshot();
+ var original = RunTestData.CreateRun();
+ var run = original with
+ {
+ SchemaVersion = RecordedRun.TuneAttachmentSchemaVersion,
+ TuneAttachment = new(snapshot, "Saved setup", "Tune snapshot", DateTimeOffset.UtcNow, RunTuneAttachmentKind.CurrentAtStart),
+ Samples = original.Samples.Select(sample => sample with
+ {
+ State = sample.State with
+ {
+ CarOrdinal = snapshot.Identity.CarOrdinal,
+ Drivetrain = (DrivetrainType)snapshot.Identity.Drivetrain,
+ SmashableVelocityLossMetersPerSecond = 0,
+ SmashableMassKilograms = 0
+ }
+ }).ToArray()
+ };
+ var store = new RunStore(_directory);
+ await store.SaveAsync(run);
+ var loaded = await store.LoadAsync(run.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(JsonSerializer.Serialize(run.TuneAttachment, RunStore.JsonOptions),
+ JsonSerializer.Serialize(loaded.TuneAttachment, RunStore.JsonOptions));
+ await store.SetContactMarkerAsync(run.Id, 0, true);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, (await store.LoadAsync(run.Id)).SchemaVersion);
+ }
+
+ [Fact]
+ public async Task NewJournalDeclaresContactCapableFormatBeforeTheFirstSample()
+ {
+ var store = new RunStore(_directory);
+ var run = RunTestData.CreateRun();
+ var contact = run.Samples[1] with
+ {
+ State = run.Samples[1].State with
+ {
+ SmashableVelocityLossMetersPerSecond = 2,
+ SmashableMassKilograms = 150
+ }
+ };
+ await using (var journal = store.CreateJournal(run with { Samples = [] }))
+ {
+ await journal.AppendAsync(run.Samples[0]);
+ await journal.AppendAsync(contact);
+ await journal.FlushAsync();
+ }
+ var path = Path.Combine(_directory, $"{run.Id:N}.partial");
+ using (var reader = File.OpenText(path))
+ using (var header = JsonDocument.Parse((await reader.ReadLineAsync(TestContext.Current.CancellationToken))!))
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, header.RootElement.GetProperty("schemaVersion").GetInt32());
+ var recovered = new RunStore(_directory);
+ Assert.Single(await recovered.ListAsync());
+ var loaded = await recovered.LoadAsync(run.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, loaded.SchemaVersion);
+ Assert.Equal([run.Samples[0], contact], loaded.Samples);
+ }
+
+ [Fact]
+ public async Task UnsupportedFutureJournalWithReadablePrefixIsKeptIntact()
+ {
+ Directory.CreateDirectory(_directory);
+ var run = RunTestData.CreateRun() with { SchemaVersion = RecordedRun.CurrentSchemaVersion + 1 };
+ var path = Path.Combine(_directory, $"{run.Id:N}.partial");
+ var lines = new[] { JsonSerializer.Serialize(run with { Samples = [] }, RunStore.JsonOptions),
+ JsonSerializer.Serialize(run.Samples[0], RunStore.JsonOptions), "{\"futureSampleField\":1}" };
+ await File.WriteAllLinesAsync(path, lines, TestContext.Current.CancellationToken);
+ var original = await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken);
+ var store = new RunStore(_directory);
+ Assert.Empty(await store.ListAsync());
+ Assert.Equal(original, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken));
+ Assert.False(File.Exists(Path.Combine(_directory, $"{run.Id:N}.wisprun")));
+ }
+
+ [Fact]
+ public void ContactTelemetryCannotValidateAsLegacyFormat()
+ {
+ var run = RunTestData.CreateRun();
+ run = run with
+ {
+ Samples = run.Samples.Select(sample => sample with
+ {
+ State = sample.State with
+ {
+ SmashableVelocityLossMetersPerSecond = 0,
+ SmashableMassKilograms = 0
+ }
+ }).ToArray()
+ };
+ Assert.Throws(() => RunStore.Validate(run));
+ RunStore.Validate(run with { SchemaVersion = RecordedRun.CurrentSchemaVersion });
+ }
+
+ [Theory]
+ [InlineData(float.NaN, 10f)]
+ [InlineData(1f, float.PositiveInfinity)]
+ [InlineData(-1f, 10f)]
+ [InlineData(501f, 10f)]
+ [InlineData(1f, -1f)]
+ [InlineData(1f, 10_000_001f)]
+ [InlineData(null, 10f)]
+ [InlineData(1f, null)]
+ public void InvalidOrPartialObjectImpactMetadataCannotEnterTheRunLibrary(float? loss, float? mass)
+ {
+ var sample = RunTestData.CreateRun().Samples[0];
+ sample = sample with { State = sample.State with { SmashableVelocityLossMetersPerSecond = loss, SmashableMassKilograms = mass } };
+ Assert.Throws(() => RunStore.ValidateSample(sample, null));
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs b/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs
index 5294578..f7438f7 100644
--- a/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs
+++ b/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs
@@ -33,7 +33,7 @@ public async Task LegacyRunRemainsV1WithItsFreeTextTuneAndNoNewHeaderMember()
[Theory]
[InlineData(1, true)]
[InlineData(2, false)]
- [InlineData(3, true)]
+ [InlineData(4, true)]
public void SchemaAndStructuredAttachmentMustAgree(int version, bool attach)
{
var run = AttachedRun();
@@ -139,23 +139,34 @@ public async Task SingleAndBulkArchiveRoundTripsRetainEveryAttachmentField()
Assert.Equal(JsonSerializer.Serialize(run, RunStore.JsonOptions), JsonSerializer.Serialize(loadedBulk, RunStore.JsonOptions));
}
- [Fact]
- public async Task InterruptedV2JournalRecoversItsSnapshotAndMarksContinuityUnverified()
+ [Theory]
+ [InlineData(false, 2)]
+ [InlineData(true, 3)]
+ public async Task InterruptedJournalRecoversItsSnapshotAndMarksContinuityUnverified(bool newJournal, int expectedVersion)
{
var run = AttachedRun() with { Tune = "Legacy label" };
var store = new RunStore(Folder("recovery"));
- await using (var journal = store.CreateJournal(run with { Samples = [] }))
+ var journalPath = Path.Combine(Folder("recovery"), $"{run.Id:N}.partial");
+ if (newJournal)
{
+ await using var journal = store.CreateJournal(run with { Samples = [] });
foreach (var sample in run.Samples) await journal.AppendAsync(sample);
await journal.FlushAsync();
Assert.Empty(await store.ListAsync());
}
- var journalPath = Path.Combine(Folder("recovery"), $"{run.Id:N}.partial");
+ else
+ {
+ Directory.CreateDirectory(Folder("recovery"));
+ await File.WriteAllLinesAsync(journalPath,
+ new[] { JsonSerializer.Serialize(run with { Samples = [] }, RunStore.JsonOptions) }
+ .Concat(run.Samples.Select(sample => JsonSerializer.Serialize(sample, RunStore.JsonOptions))),
+ TestContext.Current.CancellationToken);
+ }
await File.AppendAllTextAsync(journalPath, "{\"elapsedSeconds\":", TestContext.Current.CancellationToken);
var recovered = new RunStore(Folder("recovery"));
Assert.Single(await recovered.ListAsync());
var loaded = await recovered.LoadAsync(run.Id);
- Assert.Equal(2, loaded.SchemaVersion);
+ Assert.Equal(expectedVersion, loaded.SchemaVersion);
Assert.True(loaded.IsIncomplete);
Assert.True(loaded.TuneAttachment!.DrivingContinuityInterrupted);
Assert.Equal(run.Tune, loaded.Tune);
@@ -172,7 +183,7 @@ private static RecordedRun AttachedRun(RunTuneAttachment? attachment = null)
var run = RunTestData.CreateRun();
return run with
{
- SchemaVersion = RecordedRun.CurrentSchemaVersion,
+ SchemaVersion = RecordedRun.TuneAttachmentSchemaVersion,
TuneAttachment = attachment,
Samples = run.Samples.Select(sample => sample with
{
diff --git a/tests/Wisp.Core.Tests/LapReviewContactsTests.cs b/tests/Wisp.Core.Tests/LapReviewContactsTests.cs
new file mode 100644
index 0000000..1d3a0c3
--- /dev/null
+++ b/tests/Wisp.Core.Tests/LapReviewContactsTests.cs
@@ -0,0 +1,201 @@
+using Wisp.Core.Runs;
+using Xunit;
+
+namespace Wisp.Core.Tests;
+
+public sealed class LapReviewContactsTests
+{
+ private static LapReviewLap Lap(Func? speedAt = null, Func? accelerationAt = null, int count = 100)
+ {
+ var position = 0f;
+ var points = new LapReviewPoint[count];
+ for (var index = 0; index < count; index++)
+ {
+ var time = index * .02;
+ var speed = speedAt?.Invoke(index) ?? (index < 20 ? 30 : 20);
+ position += speed * .02f;
+ var location = new LapPosition(position, 0, 0);
+ var state = TestVehicleState.Create(groundSpeed: speed) with
+ {
+ GameTimestampMilliseconds = (uint)(index * 20 + 1000),
+ LocalVelocityYMetersPerSecond = 0,
+ LongitudinalAccelerationMetersPerSecondSquared = accelerationAt?.Invoke(index) ?? (index == 20 ? -100 : 0),
+ Lap = new(location, (float)time, 0, (float)time, 0, 1)
+ };
+ var sample = new RunSample { ElapsedSeconds = time, IsDriving = true, State = state };
+ points[index] = new(index, time, time, position, location, index == 0, sample);
+ }
+ return new() { Points = points };
+ }
+
+ [Fact]
+ public void CorroboratedAbruptSlowingIsExplicitlyOnlyAPossibleContact()
+ {
+ var contact = Assert.Single(LapReviewContacts.Find(Lap(), TestContext.Current.CancellationToken));
+ Assert.Equal(20, contact.PointIndex);
+ Assert.Equal("Possible contact", contact.Label);
+ Assert.Equal(LapReviewContactKind.PossibleContact, contact.Kind);
+ Assert.Contains("may miss or misidentify", LapReviewContacts.EvidenceNote);
+ }
+
+ [Fact]
+ public void OrdinaryBrakingAndAccelerationAloneAreNotContacts()
+ {
+ Assert.Empty(LapReviewContacts.Find(Lap(index => 40 - index * .2f, _ => -10), TestContext.Current.CancellationToken));
+ Assert.Empty(LapReviewContacts.Find(Lap(_ => 30, _ => -100), TestContext.Current.CancellationToken));
+ Assert.Empty(LapReviewContacts.Find(Lap(accelerationAt: _ => 0), TestContext.Current.CancellationToken));
+ Assert.Empty(LapReviewContacts.Find(Lap(index => index < 20 ? 5 : 0), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void OnePacketSpeedErrorIsNotAContact()
+ {
+ Assert.Empty(LapReviewContacts.Find(Lap(index => index == 20 ? 15 : 30), TestContext.Current.CancellationToken));
+ }
+
+ private static LapReviewLap WithImpactFields(LapReviewLap lap, Func lossAt, Func massAt)
+ {
+ for (var index = 0; index < lap.Points.Length; index++)
+ {
+ var point = lap.Points[index];
+ lap.Points[index] = point with
+ {
+ Sample = point.Sample with
+ {
+ State = point.Sample.State with
+ {
+ SmashableVelocityLossMetersPerSecond = lossAt(index),
+ SmashableMassKilograms = massAt(index)
+ }
+ }
+ };
+ }
+ return lap;
+ }
+
+ [Fact]
+ public void DirectBreakableObjectSignalDoesNotRequireAHeuristicSpeedDrop()
+ {
+ var lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 2 : 0, index => index >= 20 ? 150 : 0);
+ var contact = Assert.Single(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ Assert.Equal(20, contact.PointIndex);
+ Assert.Equal(LapReviewContactKind.SmashableObject, contact.Kind);
+ Assert.Equal("Object contact", contact.Label);
+ }
+
+ [Fact]
+ public void AReportedObjectImpactDoesNotAlsoBecomeAnInferredContact()
+ {
+ var lap = WithImpactFields(Lap(), index => index >= 20 ? 2 : 0, index => index >= 20 ? 150 : 0);
+ Assert.Equal(LapReviewContactKind.SmashableObject,
+ Assert.Single(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken)).Kind);
+ }
+
+ [Fact]
+ public void LatchedMassAndDecayingImpulseDoNotCreateRepeatedObjectContacts()
+ {
+ var lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 4 - index * .02f : 0, _ => 150);
+ Assert.Single(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ lap = WithImpactFields(Lap(_ => 30, _ => 0), _ => 0, _ => 150);
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ lap = WithImpactFields(Lap(_ => 30, _ => 0), _ => 2, _ => 150);
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void FreshObjectImpulsesAfterAnObservedResetAreSeparateContacts()
+ {
+ var lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index is 20 or 25 ? 2 : 0, _ => 150);
+ Assert.Equal([20, 25], LapReviewContacts.Find(lap, TestContext.Current.CancellationToken).Select(contact => contact.PointIndex));
+ }
+
+ [Fact]
+ public void DirectObjectMarkerCannotCrossATelemetryGapOrStartFromUnavailableData()
+ {
+ var lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 2 : 0, _ => 150);
+ lap.Points[20] = lap.Points[20] with { BreakBefore = true };
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 2 : null, _ => 150);
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 2 : 0, _ => float.NaN);
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData("break")]
+ [InlineData("segment")]
+ [InlineData("clock")]
+ [InlineData("rewind")]
+ [InlineData("jump")]
+ [InlineData("airborne")]
+ [InlineData("missingVertical")]
+ [InlineData("invalidAcceleration")]
+ public void MissingOrDiscontinuousEvidenceDoesNotProduceAContact(string kind)
+ {
+ var lap = Lap();
+ var points = lap.Points;
+ for (var index = 20; index < points.Length; index++)
+ {
+ var point = points[index];
+ points[index] = kind switch
+ {
+ "break" => point with { BreakBefore = true },
+ "segment" => point with { Sample = point.Sample with { Segment = 1 } },
+ "clock" => point with { Sample = point.Sample with { State = point.Sample.State with { GameTimestampMilliseconds = (uint)(100000 + index * 20) } } },
+ "rewind" => point with { LapSeconds = point.LapSeconds - .1 },
+ "jump" => point with { Position = point.Position with { X = point.Position.X + 100 } },
+ "airborne" => point with { Sample = point.Sample with { State = point.Sample.State with { LocalVelocityYMetersPerSecond = 4 } } },
+ "missingVertical" => point with { Sample = point.Sample with { State = point.Sample.State with { LocalVelocityYMetersPerSecond = null } } },
+ _ => point with { Sample = point.Sample with { State = point.Sample.State with { LongitudinalAccelerationMetersPerSecondSquared = float.NaN } } }
+ };
+ }
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void CooldownGroupsOneImpactButAllowsASeparateLaterImpact()
+ {
+ var lap = Lap(index => index < 20 ? 80 : index < 30 ? 70 : index < 90 ? 60 : 40,
+ index => index is 20 or 30 or 90 ? -100 : 0, count: 110);
+ Assert.Equal([20, 90], LapReviewContacts.Find(lap, TestContext.Current.CancellationToken).Select(contact => contact.PointIndex));
+ }
+
+ [Fact]
+ public void ExplicitMarkersAreDistinctAndMapOnlyToTheirLap()
+ {
+ var lap = Lap();
+ RunMarker[] markers = [new(.4, "Contact"), new(.4, "contact"), new(.8, "Apex"), new(100, "Contact"), new(double.NaN, "Contact")];
+ var contact = Assert.Single(LapReviewContacts.FromMarkers(lap, markers, TestContext.Current.CancellationToken));
+ Assert.Equal(20, contact.PointIndex);
+ Assert.Equal(LapReviewContactKind.UserMarkedContact, contact.Kind);
+ Assert.Equal("Marked contact", contact.Label);
+ }
+
+ [Fact]
+ public void MarkerDoesNotJumpAnUnrecordedGap()
+ {
+ var lap = Lap();
+ lap.Points[20] = lap.Points[20] with { BreakBefore = true };
+ Assert.Empty(LapReviewContacts.FromMarkers(lap, [new(.39, "Contact")], TestContext.Current.CancellationToken));
+ Assert.Single(LapReviewContacts.FromMarkers(lap, [new(.4, "Contact")], TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void AContactBetweenSamplesKeepsItsStoredTimestampForExactRemoval()
+ {
+ var contact = Assert.Single(LapReviewContacts.FromMarkers(Lap(), [new(.391, "Contact")], TestContext.Current.CancellationToken));
+ Assert.Equal(20, contact.PointIndex);
+ Assert.Equal(.391, contact.RunSeconds);
+ }
+
+ [Fact]
+ public void EmptyAndCancelledAnalysisDoNotStartWork()
+ {
+ Assert.Empty(LapReviewContacts.Find(new(), TestContext.Current.CancellationToken));
+ Assert.Empty(LapReviewContacts.FromMarkers(new(), [], TestContext.Current.CancellationToken));
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ Assert.Throws(() => LapReviewContacts.Find(Lap(), cancellation.Token));
+ Assert.Throws(() => LapReviewContacts.FromMarkers(Lap(), [], cancellation.Token));
+ }
+}
diff --git a/tests/Wisp.Telemetry.Tests/SmashablePacketTests.cs b/tests/Wisp.Telemetry.Tests/SmashablePacketTests.cs
new file mode 100644
index 0000000..ea5b49c
--- /dev/null
+++ b/tests/Wisp.Telemetry.Tests/SmashablePacketTests.cs
@@ -0,0 +1,50 @@
+using Xunit;
+
+namespace Wisp.Telemetry.Tests;
+
+public sealed class SmashablePacketTests
+{
+ [Theory]
+ [InlineData(0f, 0f)]
+ [InlineData(2.5f, 120f)]
+ [InlineData(500f, 10_000_000f)]
+ public void DocumentedHorizonImpactFieldsKeepIndependentDashOffsets(float loss, float mass)
+ {
+ var packet = Fh6PacketFixture.Create();
+ Fh6PacketFixture.WriteInt32(packet, 232, 37);
+ Fh6PacketFixture.WriteSingle(packet, 236, loss);
+ Fh6PacketFixture.WriteSingle(packet, 240, mass);
+ Fh6PacketFixture.WriteSingle(packet, 244, 180.5f);
+ Fh6PacketFixture.WriteSingle(packet, 248, 20.25f);
+ Fh6PacketFixture.WriteSingle(packet, 252, -30f);
+ Assert.True(new Fh6PacketParser().TryParse(packet, DateTimeOffset.UtcNow, out var state, out var error));
+ Assert.Equal(PacketParseError.None, error);
+ Assert.Equal(loss, state!.SmashableVelocityLossMetersPerSecond);
+ Assert.Equal(mass, state.SmashableMassKilograms);
+ Assert.Equal(180.5f, state.Lap!.Position.X);
+ Assert.Equal(20.25f, state.Lap.Position.Y);
+ Assert.Equal(-30f, state.Lap.Position.Z);
+ Assert.Equal(42.25f, state.GroundSpeedMetersPerSecond);
+ Assert.Equal(236, Fh6PacketLayout.SmashableVelocityLoss);
+ Assert.Equal(240, Fh6PacketLayout.SmashableMass);
+ }
+
+ [Theory]
+ [InlineData(float.NaN, 10f)]
+ [InlineData(2f, float.PositiveInfinity)]
+ [InlineData(-1f, 10f)]
+ [InlineData(501f, 10f)]
+ [InlineData(2f, -1f)]
+ [InlineData(2f, 10_000_001f)]
+ public void InvalidOptionalImpactDataDoesNotDiscardHealthyTelemetry(float loss, float mass)
+ {
+ var packet = Fh6PacketFixture.Create();
+ Fh6PacketFixture.WriteSingle(packet, 236, loss);
+ Fh6PacketFixture.WriteSingle(packet, 240, mass);
+ Assert.True(new Fh6PacketParser().TryParse(packet, DateTimeOffset.UtcNow, out var state, out var error));
+ Assert.Equal(PacketParseError.None, error);
+ Assert.Null(state!.SmashableVelocityLossMetersPerSecond);
+ Assert.Null(state.SmashableMassKilograms);
+ Assert.Equal(42.25f, state.GroundSpeedMetersPerSecond);
+ }
+}
diff --git a/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs b/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs
new file mode 100644
index 0000000..af89142
--- /dev/null
+++ b/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs
@@ -0,0 +1,268 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using Wisp.App;
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+
+namespace Wisp.UiReview;
+
+internal static class LapReview3DPerformanceReview
+{
+ internal static int Run(string source, string output, Func loadResources)
+ {
+ using var deadline = new System.Threading.Timer(_ => Environment.Exit(124), null, TimeSpan.FromSeconds(120), Timeout.InfiniteTimeSpan);
+ using var bindings = new BindingTrace();
+ var previousContext = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
+ var application = new ResourceApplication { ShutdownMode = ShutdownMode.OnExplicitShutdown };
+ var failures = new List();
+ var measurements = new List();
+ string? sourceSha256 = null;
+ var sourceSamples = 0; var lapPoints = 0; var maximumPoints = 0; var realSegments = 0; var stressSegments = 0;
+ double elevationSpan = 0;
+ IReadOnlyDictionary previewContacts = new Dictionary();
+ LapReviewTrack3D? track = null;
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
+ RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly;
+ application.Resources = loadResources();
+ var path = Path.GetFullPath(source);
+ if (!File.Exists(path) || !string.Equals(Path.GetExtension(path), ".wisprun", StringComparison.OrdinalIgnoreCase) ||
+ !Guid.TryParseExact(Path.GetFileNameWithoutExtension(path), "N", out var id))
+ throw new InvalidDataException("A saved run with its original library filename is required.");
+ sourceSha256 = Hash(path);
+ var load = new RunStore(Path.GetDirectoryName(path)!).LoadAsync(id);
+ Await(load);
+ var run = load.GetAwaiter().GetResult();
+ sourceSamples = run.Samples.Length;
+ LapReviewResult result = null!;
+ Measure("recorded-lap-analysis", 1, () => result = LapReviewAnalysis.Build(run));
+ var lap = result.Laps.OrderByDescending(item => item.IsComplete).ThenByDescending(item => item.Points.Length).FirstOrDefault();
+ if (lap is null || lap.Points.Length < 2) throw new InvalidDataException("No reviewable recorded lap was found.");
+ lapPoints = lap.Points.Length;
+ elevationSpan = lap.Points.Max(point => point.Position.Y) - lap.Points.Min(point => point.Position.Y);
+ var data = new LapReviewPlotData(lap, null, null, LapReviewChannel.Speed, SpeedUnit.MilesPerHour,
+ 0, 0, lap.Points.Length - 1);
+ track = new LapReviewTrack3D { Data = data };
+ var surface = new Border { Background = (Brush)application.FindResource("WindowBrush"), Child = track };
+ VisualTreeHelper.SetRootDpi(surface, new DpiScale(1, 1));
+ LapReviewUiChecks.Arrange(surface, new(1280, 720));
+ Measure("recorded-3d-scene-preparation", 1, () => Await(track.PrepareForTestAsync()));
+ Check(track.IsReady, "recorded-scene-ready");
+ realSegments = track.PreparedSegmentCount;
+ Check(realSegments is > 0 and < LapReviewTrackGeometry.MaximumPathPoints, "recorded-scene-bounded");
+ var bounds = LapReviewTrackBounds.From(data);
+ var normalizedSpan = lap.Points.Max(point => bounds.Normalize(point.Position).Y) -
+ lap.Points.Min(point => bounds.Normalize(point.Position).Y);
+ Check(Math.Abs(normalizedSpan * bounds.Span - elevationSpan) < .001, "recorded-elevation-scale-preserved");
+ Capture(surface, "recorded-3d-default");
+
+ var builds = track.SceneBuildCount;
+ const int changes = 360;
+ Measure("camera-property-updates-no-render", changes, () =>
+ {
+ for (var i = 0; i < changes; i++)
+ {
+ track.Yaw = i - 180; track.Pitch = 30 + 15 * Math.Sin(i * Math.PI / 180);
+ track.Roll = 10 * Math.Sin(i * Math.PI / 90); track.ZoomFactor = 1 + .2 * Math.Sin(i * Math.PI / 180);
+ }
+ });
+ Check(track.SceneBuildCount == builds, "camera-keeps-prepared-scene");
+ Measure("cursor-property-updates-no-render", changes, () =>
+ {
+ for (var i = 0; i < changes; i++) track.Data = data with { Cursor = i * (lap.Points.Length - 1) / (changes - 1) };
+ });
+ Check(track.SceneBuildCount == builds, "cursor-keeps-prepared-scene");
+ Check(track.ProjectPoint(lap.Points.Length / 2) is var p && double.IsFinite(p.X) && double.IsFinite(p.Y), "recorded-point-projects");
+ track.Yaw = -65; track.Pitch = 18; track.Roll = 5; track.ZoomFactor = 1;
+ Capture(surface, "recorded-3d-rotated");
+ CaptureActualView(run, Path.GetDirectoryName(path)!);
+
+ // Dense interpolation of this recorded geometry exercises the accepted size limit;
+ // it is a synthetic stress case, not another measured gameplay recording.
+ LapReviewLap dense = null!;
+ Measure("synthetic-dense-fixture", 1, () => dense = Densify(lap));
+ maximumPoints = dense.Points.Length;
+ Measure("synthetic-180000-point-scene-preparation", 1, () =>
+ {
+ track.Data = data with { Lap = dense, Cursor = 0, SectionEnd = dense.Points.Length - 1 };
+ Await(track.PrepareForTestAsync());
+ });
+ stressSegments = track.PreparedSegmentCount;
+ Check(track.IsReady && maximumPoints == LapReviewAnalysis.MaximumPoints, "maximum-source-size-ready");
+ Check(stressSegments is > 0 and < LapReviewTrackGeometry.MaximumPathPoints, "maximum-scene-bounded");
+ builds = track.SceneBuildCount;
+ Measure("maximum-cursor-updates-no-render", changes, () =>
+ {
+ var denseData = track.Data!;
+ for (var i = 0; i < changes; i++) track.Data = denseData with { Cursor = i * (maximumPoints - 1) / (changes - 1) };
+ });
+ Check(track.SceneBuildCount == builds, "maximum-cursor-keeps-prepared-scene");
+ Capture(surface, "synthetic-180000-point-3d");
+ Check(PresentationSource.FromVisual(surface) is null && application.Windows.Count == 0, "no-presentation-window");
+ Check(Hash(path) == sourceSha256, "original-run-unchanged");
+ }
+ catch (Exception error) { failures.Add(error.GetType().Name); }
+ finally
+ {
+ if (track is not null) { track.Data = null; Await(track.PrepareForTestAsync()); }
+ application.Shutdown();
+ SynchronizationContext.SetSynchronizationContext(previousContext);
+ }
+ if (bindings.TotalCount != 0) failures.Add("binding-diagnostics");
+ File.WriteAllText(Path.Combine(output, "lap-review-3d-performance.json"), JsonSerializer.Serialize(new
+ {
+ method = "Read-only stored lap analysis and actual WPF 3D scene. Detached 1280x720 software rendering and the production Lap Review view at 1280x900; its map PNG uses the production bounded 2x export. No game, window, controller, UDP listener or settings access. Camera/cursor timings measure CPU property updates, not rendered or displayed FPS. Allocation deltas are process-wide managed allocations. The 180000-point case is dense interpolation of recorded geometry, not gameplay.",
+ sourceSha256,
+ sourceSamples,
+ lapPoints,
+ elevationSpanMeters = elevationSpan,
+ realSegments,
+ maximumPoints,
+ stressSegments,
+ previewContactEvidence = new
+ {
+ countsByKind = previewContacts,
+ note = "Only production contact analysis and original stored run markers are used. No synthetic contact markers are added. PossibleContact entries are estimates, not confirmed collisions."
+ },
+ measurements,
+ failures,
+ bindingDiagnosticCount = bindings.TotalCount
+ }, new JsonSerializerOptions { WriteIndented = true }));
+ Console.WriteLine($"Lap review 3D: {(failures.Count == 0 ? "PASS" : "FAIL")}; {lapPoints} recorded points; {maximumPoints} synthetic stress points; {failures.Count} failures.");
+ return failures.Count == 0 ? 0 : 2;
+
+ void Check(bool condition, string code) { if (!condition) failures.Add(code); }
+ void Measure(string name, int operations, Action action)
+ {
+ var allocated = GC.GetTotalAllocatedBytes(precise: true);
+ var timer = Stopwatch.StartNew(); action(); timer.Stop();
+ measurements.Add(new(name, operations, timer.Elapsed.TotalMilliseconds, GC.GetTotalAllocatedBytes(precise: true) - allocated));
+ }
+ void Capture(FrameworkElement surface, string name)
+ {
+ LapReviewUiChecks.Arrange(surface, new(1280, 720));
+ var bitmap = new RenderTargetBitmap(1280, 720, 96, 96, PixelFormats.Pbgra32);
+ Measure(name + "-software-render", 1, () => bitmap.Render(surface));
+ var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap));
+ using var file = new FileStream(Path.Combine(output, name + ".png"), FileMode.CreateNew, FileAccess.Write);
+ encoder.Save(file);
+ }
+ void CaptureActualView(RecordedRun run, string runDirectory)
+ {
+ var settings = new AppSettings { StartWithWindows = false, StartWithForza = false, AutomaticApplicationUpdateChecks = false };
+ using var model = new LapReviewViewModel(settings, new RunStore(runDirectory),
+ () => throw new InvalidOperationException("The preview must not save settings."));
+ var view = new LapReviewView { DataContext = model };
+ // Lap Review normally inherits its selector templates from RunsPage.
+ // Reuse those exact resources for this detached production preview.
+ view.Resources.MergedDictionaries.Add(new RunsPage().Resources);
+ var scroll = new ScrollViewer
+ {
+ Content = view,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
+ };
+ var preview = new Border
+ {
+ Padding = new Thickness(16),
+ Background = (Brush)application.FindResource("WindowBrush"),
+ Child = scroll
+ };
+ var size = new Size(1280, 900);
+ VisualTreeHelper.SetRootDpi(preview, new DpiScale(1, 1));
+ var actualTrack = (LapReviewTrack3D)view.FindName("Track3D");
+ try
+ {
+ Measure("production-view-lap-analysis", 1, () =>
+ {
+ model.SetRuns(run, null);
+ var timeout = Stopwatch.StartNew();
+ do { Await(Task.Delay(10)); } while (model.IsBusy && timeout.Elapsed < TimeSpan.FromSeconds(20));
+ if (model.IsBusy || !model.HasLap) throw new InvalidOperationException("The recorded lap preview did not become ready.");
+ });
+ model.Is3D = true;
+ LapReviewUiChecks.Arrange(preview, size);
+ Measure("production-view-3d-preparation", 1, () => Await(actualTrack.PrepareForTestAsync()));
+ actualTrack.ResetView();
+ LapReviewUiChecks.Arrange(preview, size);
+ Check(actualTrack.IsReady, "production-map-ready");
+ Check(ReferenceEquals(actualTrack.Data?.Lap, model.Lap), "production-map-uses-selected-recorded-lap");
+ previewContacts = (model.Plot.Contacts ?? Array.Empty())
+ .GroupBy(contact => contact.Kind).ToDictionary(group => group.Key.ToString(), group => group.Count());
+
+ BitmapSource map = null!;
+ Measure("production-map-export-render", 1, () => map = view.CaptureMapImage());
+ Check(map.IsFrozen && map.PixelWidth > 0 && map.PixelWidth <= 3840 && map.PixelHeight > 0 && map.PixelHeight <= 2160,
+ "production-map-export-frozen-and-bounded");
+ Await(RunImageExporter.WriteAsync(map, Path.Combine(output, "actual-map-export.png")));
+
+ var toolbar = view.FindName("MapToolbar") as FrameworkElement ?? (FrameworkElement)view.FindName("MapExportSurface");
+ scroll.ScrollToVerticalOffset(Math.Max(0, toolbar.TranslatePoint(new Point(), view).Y - 10));
+ LapReviewUiChecks.Arrange(preview, size);
+ var fullView = new RenderTargetBitmap(2560, 1800, 192, 192, PixelFormats.Pbgra32);
+ Measure("production-view-software-render", 1, () => fullView.Render(preview));
+ fullView.Freeze();
+ Await(RunImageExporter.WriteAsync(fullView, Path.Combine(output, "actual-lap-review-3d.png")));
+ Check(PresentationSource.FromVisual(preview) is null && application.Windows.Count == 0,
+ "production-preview-has-no-window");
+ }
+ finally
+ {
+ view.DataContext = null;
+ actualTrack.Data = null;
+ Await(actualTrack.PrepareForTestAsync());
+ }
+ }
+ }
+
+ private static LapReviewLap Densify(LapReviewLap lap)
+ {
+ var points = new LapReviewPoint[LapReviewAnalysis.MaximumPoints];
+ var previous = -1;
+ for (var i = 0; i < points.Length; i++)
+ {
+ var position = i * (lap.Points.Length - 1d) / (points.Length - 1);
+ var index = Math.Min((int)position, lap.Points.Length - 2);
+ var t = position - index;
+ var a = lap.Points[index]; var b = lap.Points[index + 1];
+ points[i] = a with
+ {
+ SampleIndex = i,
+ Position = new((float)Mix(a.Position.X, b.Position.X, t), (float)Mix(a.Position.Y, b.Position.Y, t), (float)Mix(a.Position.Z, b.Position.Z, t)),
+ DistanceMeters = Mix(a.DistanceMeters, b.DistanceMeters, t),
+ LapSeconds = Mix(a.LapSeconds, b.LapSeconds, t),
+ RunSeconds = Mix(a.RunSeconds, b.RunSeconds, t),
+ BreakBefore = i == 0 || index != previous && a.BreakBefore
+ };
+ previous = index;
+ }
+ return lap with { Points = points };
+ }
+ private static double Mix(double a, double b, double t) => a + (b - a) * t;
+ private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
+ private static void Await(Task task)
+ {
+ if (!task.IsCompleted)
+ {
+ var dispatcher = Dispatcher.CurrentDispatcher; var frame = new DispatcherFrame();
+ _ = task.ContinueWith(_ => dispatcher.InvokeAsync(() => frame.Continue = false), TaskScheduler.Default);
+ Dispatcher.PushFrame(frame);
+ }
+ task.GetAwaiter().GetResult();
+ }
+ private sealed record Measurement(string Name, int Operations, double TotalMilliseconds, long ManagedAllocatedBytes);
+ private sealed class ResourceApplication : Application { protected override void OnStartup(StartupEventArgs e) { } }
+}
diff --git a/tools/Wisp.UiReview/LapReviewFixtures.cs b/tools/Wisp.UiReview/LapReviewFixtures.cs
index fbdc328..e98cd75 100644
--- a/tools/Wisp.UiReview/LapReviewFixtures.cs
+++ b/tools/Wisp.UiReview/LapReviewFixtures.cs
@@ -26,7 +26,7 @@ internal static RecordedRun Create(bool reference = false)
CarOrdinal = 101,
Drivetrain = DrivetrainType.RearWheelDrive,
NumCylinders = 8,
- Lap = new(new((float)(210 * Math.Cos(angle)), 0, (float)(140 * Math.Sin(angle))),
+ Lap = new(new((float)(210 * Math.Cos(angle)), (float)(80 + 24 * Math.Sin(angle) + 7 * Math.Sin(angle * 3)), (float)(140 * Math.Sin(angle))),
index % samplesPerLap * stepMilliseconds / 1000f, duration, (float)elapsed,
(ushort)(index / samplesPerLap), 1),
GroundSpeedMetersPerSecond = speed,
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index e1e2d46..a41d5e3 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -1,11 +1,13 @@
using System.Diagnostics;
using System.IO;
+using System.Security.Cryptography;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
+using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Wisp.App;
using Wisp.App.Runs;
@@ -44,7 +46,12 @@ internal static void Run(string unusedStoreDirectory, Action check
var view = Descendants(expander).OfType().Single();
var review = model.LapReview;
var track = (LapReviewPlot)view.FindName("Track");
+ var track3D = (LapReviewTrack3D)view.FindName("Track3D");
var trace = (LapReviewPlot)view.FindName("Trace");
+ var view2D = (RadioButton)view.FindName("View2D");
+ var view3D = (RadioButton)view.FindName("View3D");
+ var mapExportSurface = (FrameworkElement)view.FindName("MapExportSurface");
+ var saveMap = (Button)view.FindName("SaveMapPng");
var scroll = (ScrollViewer)page.FindName("RunsScroll");
var combos = Descendants(view).OfType().ToArray();
var channel = combos.Single(item => AutomationProperties.GetName(item) == "Lap map and graph channel");
@@ -54,7 +61,7 @@ internal static void Run(string unusedStoreDirectory, Action check
var emptyReferenceHint = (TextBlock)view.FindName("EmptyReferenceHint");
var cursorReadout = (Border)view.FindName("CursorReadout");
var wheelReadings = (Expander)view.FindName("WheelReadings");
- var slider = Descendants(view).OfType().Single();
+ var slider = Descendants(view).OfType().Single(item => AutomationProperties.GetName(item) == "Lap position cursor");
check(review.Lap?.IsComplete == true && review.Reference?.IsComplete == true, "completed-A-and-B");
check(review.Plot.Comparison?.CanCompare == true, "comparison-ready");
check(review.Metrics.Any(item => item.Reference != "—"), "reference-metrics-present");
@@ -62,6 +69,15 @@ internal static void Run(string unusedStoreDirectory, Action check
check(laps.IsEnabled && references.IsEnabled && emptyLapHint.Visibility == Visibility.Collapsed &&
emptyReferenceHint.Visibility == Visibility.Collapsed, "populated-lap-selectors-enabled-without-empty-hints");
check(review.PinCommand.CanExecute(null) && !review.UnpinCommand.CanExecute(null), "benchmark-command-availability");
+ check(!review.Is3D && view2D.IsChecked == true && track.Visibility == Visibility.Visible &&
+ track3D.Visibility == Visibility.Collapsed, "existing-2d-default-preserved");
+ check(review.Lap!.Points.Max(point => point.Position.Y) - review.Lap.Points.Min(point => point.Position.Y) > 30,
+ "elevated-recording-fixture");
+ check(new[] { LapReviewChannel.Speed, LapReviewChannel.Delta, LapReviewChannel.Throttle, LapReviewChannel.Brake,
+ LapReviewChannel.Steering, LapReviewChannel.LateralG, LapReviewChannel.LongitudinalG, LapReviewChannel.CombinedG,
+ LapReviewChannel.Rpm, LapReviewChannel.Gear, LapReviewChannel.TireTemperature, LapReviewChannel.SlipRatio,
+ LapReviewChannel.SlipAngle, LapReviewChannel.Suspension }.All(original => review.Channels.Any(item => item.Channel == original)),
+ "all-existing-channels-retained");
foreach (var (name, size) in new[] { ("normal", new Size(980, 750)), ("compact", new Size(720, 440)) })
{
@@ -90,6 +106,7 @@ internal static void Run(string unusedStoreDirectory, Action check
check(review.Metrics.Count > 10 && review.Metrics.Any(item => item.Reference != "—"), name + "/section-statistics");
review.Channel = review.Channels.First(item => item.Channel == LapReviewChannel.Speed); Pump();
ScrollTo(track); Capture("map-speed-section");
+ VerifyMapImage("2d", track);
ScrollTo(trace); Capture("graph-speed-section");
ScrollTo(cursorReadout); Capture("cursor-readout");
check(Descendants(cursorReadout).OfType().Any(item => item.Text == review.CursorDetails?.Speed), name + "/cursor-speed-binding");
@@ -100,6 +117,50 @@ internal static void Run(string unusedStoreDirectory, Action check
check(cursorReadout.TranslatePoint(new Point(cursorReadout.ActualWidth, 0), surface).X <= size.Width + 1, name + "/cursor-readout-within-width");
review.Channel = review.Channels.First(item => item.Channel == LapReviewChannel.Brake); Pump();
ScrollTo(track); Capture("map-brake-section");
+ var selectedLap = review.Lap; var selectedReference = review.Reference;
+ var selectedChannel = review.Channel; var selectedCursor = review.Cursor;
+ view3D.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump(); Arrange(surface, size);
+ Await(track3D.PrepareForTestAsync()); Pump();
+ check(review.Is3D && view3D.IsChecked == true && track.Visibility == Visibility.Collapsed &&
+ track3D.Visibility == Visibility.Visible, name + "/3d-mode-binding");
+ check(track3D.IsReady && track3D.PreparedSegmentCount > 0, name + "/3d-scene-ready");
+ check(ReferenceEquals(review.Lap, selectedLap) && ReferenceEquals(review.Reference, selectedReference) &&
+ ReferenceEquals(review.Channel, selectedChannel) && review.Cursor == selectedCursor &&
+ review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265, name + "/3d-keeps-review-state");
+ check(ReferenceEquals(track3D.Data?.Lap, review.Lap) && ReferenceEquals(track3D.Data?.Reference, review.Reference) &&
+ track3D.Data?.Cursor == review.Cursor && track3D.Data?.Channel == review.Channel.Channel,
+ name + "/3d-shares-recorded-data");
+ var camera = (track3D.Yaw, track3D.Pitch, track3D.Roll, track3D.ZoomFactor);
+ track3D.Yaw = camera.Yaw + 35; track3D.Pitch = camera.Pitch + 8; track3D.Roll = camera.Roll + 12;
+ track3D.ZoomBy(1.2); track3D.PanBy(12, -8); Pump();
+ check(track3D.Yaw != camera.Yaw && track3D.Pitch != camera.Pitch && track3D.Roll != camera.Roll &&
+ track3D.ZoomFactor != camera.ZoomFactor, name + "/3d-axis-and-zoom-controls");
+ slider.SetCurrentValue(RangeBase.ValueProperty, 180d); Pump();
+ check(review.Cursor == 180 && track3D.Data?.Cursor == 180 && trace.Data?.Cursor == 180,
+ name + "/3d-shared-slider-cursor");
+ SendKey(track3D, Key.Right, surface); Pump();
+ check(review.Cursor == 181 && trace.Data?.Cursor == 181, name + "/3d-arrow-cursor");
+ foreach (var choice in review.Channels)
+ {
+ channel.SetCurrentValue(Selector.SelectedItemProperty, choice); Pump(); Await(track3D.PrepareForTestAsync());
+ check(track3D.IsReady && track3D.Data?.Channel == choice.Channel && trace.Data?.Channel == choice.Channel,
+ name + "/3d-channel-" + choice.Channel);
+ }
+ review.Channel = review.Channels.First(item => item.Channel == LapReviewChannel.TireTemperature);
+ review.SelectedWheel = 3; Pump();
+ check(track3D.Data?.Wheel == 3 && trace.Data?.Wheel == 3, name + "/3d-retains-wheel-selection");
+ review.SelectedWheel = 0; review.Channel = selectedChannel; Pump(); Await(track3D.PrepareForTestAsync());
+ check(saveMap.IsEnabled && mapExportSurface.ActualWidth > 100 && mapExportSurface.ActualHeight > 100,
+ name + "/map-export-surface-ready");
+ track3D.ResetView(); Pump();
+ check(track3D.Yaw == camera.Yaw && track3D.Pitch == camera.Pitch && track3D.Roll == camera.Roll &&
+ track3D.ZoomFactor == camera.ZoomFactor, name + "/3d-reset-camera");
+ ScrollTo(mapExportSurface); Capture("map-3d-brake-section");
+ VerifyMapImage("3d", track3D);
+ view2D.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump(); Arrange(surface, size);
+ check(!review.Is3D && track.Visibility == Visibility.Visible && track3D.Visibility == Visibility.Collapsed &&
+ track.Data?.Cursor == review.Cursor && review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265,
+ name + "/2d-restores-with-shared-state");
var metrics = Descendants(view).OfType().Single(item => ReferenceEquals(item.ItemsSource, review.Metrics));
ScrollTo(metrics); Capture("section-metrics");
check(track.ActualWidth > 100 && trace.ActualWidth > 100, name + "/plots-have-layout");
@@ -127,11 +188,60 @@ void ScrollTo(FrameworkElement element)
}
}
void Capture(string stage) { Arrange(surface, size); capture?.Invoke(name + "-" + stage, surface, size); }
+ void VerifyMapImage(string mode, FrameworkElement map)
+ {
+ Arrange(surface, size);
+ var bitmap = view.CaptureMapImage();
+ var code = name + "/" + mode + "-png-";
+ check(bitmap.IsFrozen && bitmap.PixelWidth is > 0 and <= 3840 && bitmap.PixelHeight is > 0 and <= 2160,
+ code + "frozen-and-bounded");
+ var pixels = Pixels(bitmap);
+ var corners = CornerAlphas(pixels, bitmap);
+ var opaque = corners.Length == 4 && corners.All(alpha => alpha == 255);
+ check(opaque, code + "opaque-corners-without-layout-offset");
+ if (!opaque) Console.WriteLine($"{code}corner-alpha=[{string.Join(",", corners)}]; pixels={bitmap.PixelWidth}x{bitmap.PixelHeight}; surface={mapExportSurface.RenderSize.Width:R}x{mapExportSurface.RenderSize.Height:R}");
+ var mapBounds = map.TransformToAncestor(mapExportSurface).TransformBounds(new Rect(map.RenderSize));
+ var legend = Descendants(mapExportSurface).OfType()
+ .Single(item => ReferenceEquals(item.Fill, LapReviewPalette.LegendBrush));
+ var legendBounds = legend.TransformToAncestor(mapExportSurface).TransformBounds(new Rect(legend.RenderSize));
+ check(ColorfulPixels(pixels, bitmap, mapBounds, mapExportSurface.RenderSize) > 50, code + "contains-map-line");
+ check(ColorfulPixels(pixels, bitmap, legendBounds, mapExportSurface.RenderSize) > 150, code + "contains-color-legend");
+ if (name != "normal") return;
+
+ var parent = Path.GetDirectoryName(Path.GetFullPath(unusedStoreDirectory))!;
+ var scratch = Path.Combine(parent, "lap-map-png-check-" + Guid.NewGuid().ToString("N"));
+ var destination = Path.Combine(scratch, "map.png");
+ Directory.CreateDirectory(scratch);
+ try
+ {
+ Await(RunImageExporter.WriteAsync(bitmap, destination));
+ BitmapSource restored;
+ using (var file = File.OpenRead(destination))
+ {
+ var decoder = new PngBitmapDecoder(file, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
+ check(decoder.Frames.Count == 1, code + "single-frame");
+ restored = decoder.Frames[0];
+ }
+ check(restored.PixelWidth == bitmap.PixelWidth && restored.PixelHeight == bitmap.PixelHeight &&
+ SamePixels(Pixels(restored), pixels), code + "saved-pixels-round-trip");
+ var before = Hash(destination);
+ var rejected = false;
+ try { Await(RunImageExporter.WriteAsync(bitmap, destination)); }
+ catch (IOException) { rejected = true; }
+ check(rejected && Hash(destination) == before, code + "existing-image-kept");
+ }
+ finally
+ {
+ if (File.Exists(destination)) File.Delete(destination);
+ Directory.Delete(scratch, recursive: false);
+ }
+ }
}
review.SetRuns(null, null); Ready(model); Arrange(surface, new(720, 440));
check(!review.HasLap && !review.PinCommand.CanExecute(null) && !review.SectionStartCommand.CanExecute(null), "empty-review-disables-commands");
check(!channel.IsEnabled && !slider.IsEnabled, "empty-review-disables-channel-and-cursor");
+ check(!view2D.IsEnabled && !view3D.IsEnabled && !saveMap.IsEnabled, "empty-review-disables-map-tools");
check(!laps.HasItems && !references.HasItems && !laps.IsEnabled && !references.IsEnabled, "empty-lap-selectors-disabled");
check(emptyLapHint.Visibility == Visibility.Visible && emptyReferenceHint.Visibility == Visibility.Visible &&
!emptyLapHint.IsHitTestVisible && !emptyReferenceHint.IsHitTestVisible, "empty-lap-selectors-show-passive-hints");
@@ -144,7 +254,7 @@ void ScrollTo(FrameworkElement element)
check(laps.IsEnabled && references.IsEnabled && emptyLapHint.Visibility == Visibility.Collapsed &&
emptyReferenceHint.Visibility == Visibility.Collapsed, "lap-selectors-recover-when-laps-load");
check(!receiver.IsRunning && PresentationSource.FromVisual(surface) is null, "no-listener-or-presentation-window");
- check(!Directory.Exists(unusedStoreDirectory), "in-memory-fixtures-never-access-storage");
+ check(!Directory.Exists(unusedStoreDirectory), "in-memory-fixtures-never-access-run-storage");
}
finally
{
@@ -180,6 +290,58 @@ void CheckCombo(ComboBox combo)
}
}
+ private static byte[] Pixels(BitmapSource bitmap)
+ {
+ if (bitmap.Format != PixelFormats.Pbgra32) bitmap = new FormatConvertedBitmap(bitmap, PixelFormats.Pbgra32, null, 0);
+ var pixels = new byte[checked(bitmap.PixelWidth * bitmap.PixelHeight * 4)];
+ bitmap.CopyPixels(pixels, bitmap.PixelWidth * 4, 0);
+ return pixels;
+ }
+ private static int ColorfulPixels(byte[] pixels, BitmapSource bitmap, Rect region, Size surface)
+ {
+ var scaleX = bitmap.PixelWidth / surface.Width; var scaleY = bitmap.PixelHeight / surface.Height;
+ var left = Math.Clamp((int)Math.Ceiling(region.Left * scaleX), 0, bitmap.PixelWidth);
+ var right = Math.Clamp((int)Math.Floor(region.Right * scaleX), 0, bitmap.PixelWidth);
+ var top = Math.Clamp((int)Math.Ceiling(region.Top * scaleY), 0, bitmap.PixelHeight);
+ var bottom = Math.Clamp((int)Math.Floor(region.Bottom * scaleY), 0, bitmap.PixelHeight);
+ var count = 0;
+ for (var y = top; y < bottom; y++)
+ for (var x = left; x < right; x++)
+ {
+ var at = (y * bitmap.PixelWidth + x) * 4;
+ var maximum = Math.Max(pixels[at], Math.Max(pixels[at + 1], pixels[at + 2]));
+ var minimum = Math.Min(pixels[at], Math.Min(pixels[at + 1], pixels[at + 2]));
+ if (pixels[at + 3] >= 200 && maximum - minimum > 70) count++;
+ }
+ return count;
+ }
+ private static byte[] CornerAlphas(byte[] pixels, BitmapSource bitmap)
+ {
+ if (bitmap.PixelWidth < 4 || bitmap.PixelHeight < 4) return [];
+ // The one-pixel inset avoids fractional-DPI edge antialiasing while
+ // detecting layout offsets which leave unpainted image margins.
+ var result = new List(4);
+ foreach (var y in new[] { 1, bitmap.PixelHeight - 2 })
+ foreach (var x in new[] { 1, bitmap.PixelWidth - 2 })
+ result.Add(pixels[(y * bitmap.PixelWidth + x) * 4 + 3]);
+ return result.ToArray();
+ }
+ private static bool SamePixels(byte[] actual, byte[] expected)
+ {
+ if (actual.Length != expected.Length) return false;
+ for (var i = 0; i < actual.Length; i += 4)
+ {
+ if (actual[i + 3] != expected[i + 3]) return false;
+ // PNG stores straight alpha; WPF's premultiplied antialiasing can
+ // round a translucent channel by one when converting back.
+ var tolerance = expected[i + 3] == 255 ? 0 : 1;
+ for (var channel = 0; channel < 3; channel++)
+ if (Math.Abs(actual[i + channel] - expected[i + channel]) > tolerance) return false;
+ }
+ return true;
+ }
+ private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
+
internal static void Arrange(FrameworkElement surface, Size size)
{
surface.Measure(size); surface.Arrange(new Rect(size)); surface.UpdateLayout(); Pump();
diff --git a/tools/Wisp.UiReview/Program.cs b/tools/Wisp.UiReview/Program.cs
index e7504a9..ec12e1c 100644
--- a/tools/Wisp.UiReview/Program.cs
+++ b/tools/Wisp.UiReview/Program.cs
@@ -129,6 +129,11 @@ private static int Main(string[] args)
var output = PrepareOutput(args[2]);
return LapReviewUiReview.Run(output, () => LoadApplicationResources(output, out _));
}
+ if (args.Length == 5 && args[0] == "--lap-review-3d-performance-check" && args[1] == "--source" && args[3] == "--output")
+ {
+ var output = PrepareOutput(args[4]);
+ return LapReview3DPerformanceReview.Run(args[2], output, () => LoadApplicationResources(output, out _));
+ }
if (args.Length == 3 && args[0] == "--lap-delta-check" && args[1] == "--output")
{
var output = PrepareOutput(args[2]);
@@ -232,6 +237,7 @@ private static int Main(string[] args)
Console.WriteLine("--clips-lossless-product-latest-check --source --oracle --oracle-sha256 --output checks the October 2 reported 408-frame 4K clip through the actual ClipsPage player. Four paused seek samples and one sample after uninterrupted replay past 3.25 seconds match independent RGB hashes, plus muted transport and cleanup. 50-second work budget/60-second hard limit; same closed-app and nonactivating-window guards; no displayed-pixel or audible-output claim.");
Console.WriteLine("--clips-lossless-playback-check --source --sha256 --output checks current WPF decoder opening and brief muted transport after independent lossless pixel verification. Fixed 1280x720, 16-frame/60-fps source, at most 128 MiB. Requires Forza and Wisp closed; passive window, 10-second decoder budget and 20-second hard limit. No screenshots or pixel-readback claim.");
Console.WriteLine("--lap-review-check --output captures actual expanded lap review with synthetic completed A/B laps, map/graph channels and selected sections at 980x750 and 720x440. Checks themed selectors and routed keyboard actions; no window, controller, listener, settings load or saved-run access.");
+ Console.WriteLine("--lap-review-3d-performance-check --source --output reads one saved run without changing it; measures lap/3D preparation, cached camera/cursor CPU updates, detached software rendering and a synthetic 180000-point size-limit case. No window, controller, listener, game or settings access; not displayed-FPS evidence.");
Console.WriteLine("--ev-wrap-check --output captures actual EV overlay and modern/legacy Appearance previews at 100%/75% gauge scale and 96 DPI. Synthetic sample only; no displayed window or live services.");
Console.WriteLine("Main-window --present requires one --fixture, omits --scope/--dpi, and shows display-only Appearance at monitor DPI with a 120-second auto-close timer.");
Console.WriteLine("--scope wizard captures all four unconfirmed steps at four sizes and 96/144 DPI by default. --present --scope wizard shows one display-only step; --step selects it. Wizard mode never tests or completes setup.");
From 9c6260a67a4fc26f447f0cc47b8ce5d1643a8367 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 19:04:50 -0500
Subject: [PATCH 02/15] Keep lap contact annotations above the track at every
view angle
---
src/Wisp.App/Runs/LapReviewPalette.cs | 21 +++++
src/Wisp.App/Runs/LapReviewPlot.cs | 5 +-
src/Wisp.App/Runs/LapReviewTrack3D.cs | 82 ++++++++++++-------
src/Wisp.App/Runs/LapReviewTrackGeometry.cs | 38 +--------
src/Wisp.App/Runs/LapReviewView.xaml | 2 +-
tests/Wisp.App.Tests/LapReviewTrack3DTests.cs | 45 ++++++++++
.../LapReviewTrackGeometryTests.cs | 14 ++--
7 files changed, 130 insertions(+), 77 deletions(-)
diff --git a/src/Wisp.App/Runs/LapReviewPalette.cs b/src/Wisp.App/Runs/LapReviewPalette.cs
index a9d6bae..6e6eb06 100644
--- a/src/Wisp.App/Runs/LapReviewPalette.cs
+++ b/src/Wisp.App/Runs/LapReviewPalette.cs
@@ -16,6 +16,27 @@ public static class LapReviewPalette
public static Brush LegendBrush { get; } = CreateLegend();
public static Brush ContactBrush { get; } = Freeze(Color.FromRgb(255, 105, 116));
public static Brush StartBrush { get; } = Freeze(Color.FromRgb(46, 220, 175));
+ private static Geometry ContactSymbol { get; } = CreateContactSymbol();
+
+ internal static void DrawContact(DrawingContext drawing, Point position, Brush outline, bool selected = false)
+ {
+ drawing.PushTransform(new TranslateTransform(position.X, position.Y));
+ drawing.DrawGeometry(ContactBrush, new Pen(selected ? System.Windows.Media.Brushes.White : outline, 1.5), ContactSymbol);
+ drawing.Pop();
+ }
+
+ private static Geometry CreateContactSymbol()
+ {
+ var shape = new StreamGeometry();
+ using (var drawing = shape.Open())
+ {
+ drawing.BeginFigure(new Point(0, -7), true, true);
+ drawing.PolyLineTo(new Point[] { new(2.3, -2.3), new(7, 0), new(2.3, 2.3),
+ new(0, 7), new(-2.3, 2.3), new(-7, 0), new(-2.3, -2.3) }, true, false);
+ }
+ shape.Freeze();
+ return shape;
+ }
internal static Color GetColor(double fraction)
{
diff --git a/src/Wisp.App/Runs/LapReviewPlot.cs b/src/Wisp.App/Runs/LapReviewPlot.cs
index 222ec84..247c664 100644
--- a/src/Wisp.App/Runs/LapReviewPlot.cs
+++ b/src/Wisp.App/Runs/LapReviewPlot.cs
@@ -129,10 +129,7 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
if (contact.PointIndex < 0 || contact.PointIndex >= _hitPoints.Count) continue;
var p = _hitPoints[contact.PointIndex].Position;
if (!IsMap) p.Y = area.Top + 7;
- var marker = new StreamGeometry();
- using (var shape = marker.Open()) { shape.BeginFigure(new Point(p.X, p.Y - 5), true, true); shape.LineTo(new Point(p.X + 5, p.Y + 4), true, false); shape.LineTo(new Point(p.X - 5, p.Y + 4), true, false); }
- marker.Freeze();
- dc.DrawGeometry(LapReviewPalette.ContactBrush, new Pen(background, 1), marker);
+ LapReviewPalette.DrawContact(dc, p, background);
}
var unit = Unit(data);
Label(dc, finite.Length == 0 ? "No comparable values for this channel" : $"{colorMinimum:0.##} → {colorMaximum:0.##} {unit}" + (IsMap ? " · low = blue, high = red" : " · lap = cyan, reference = amber"), new(10, 5), muted);
diff --git a/src/Wisp.App/Runs/LapReviewTrack3D.cs b/src/Wisp.App/Runs/LapReviewTrack3D.cs
index e9a62fe..ea6cc7d 100644
--- a/src/Wisp.App/Runs/LapReviewTrack3D.cs
+++ b/src/Wisp.App/Runs/LapReviewTrack3D.cs
@@ -33,20 +33,18 @@ public sealed class LapReviewTrack3D : Grid
public bool IsPreparing => (bool)GetValue(IsPreparingProperty);
public string StatusText => (string)GetValue(StatusTextProperty);
public bool IsReady => _scene is not null && _path.Content is not null && Data is { } data &&
- SameScene(_preparedData, data) && _preparedContacts == ShowContacts && !IsPreparing && string.IsNullOrEmpty(StatusText);
+ SameScene(_preparedData, data) && !IsPreparing && string.IsNullOrEmpty(StatusText);
public event Action? PointChosen;
private readonly Viewport3D _viewport = new() { IsHitTestVisible = false, ClipToBounds = true };
private readonly OrthographicCamera _camera = new() { NearPlaneDistance = .01, FarPlaneDistance = 20 };
private readonly ModelVisual3D _path = new();
- private readonly ModelVisual3D _cursor = new();
- private readonly TranslateTransform3D _cursorPosition = new();
+ private readonly ContactOverlay _contactOverlay;
private readonly TextBlock _status = new() { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false };
private LapReviewTrackScene? _scene;
private LapReviewPlotData? _preparedData, _requestedData;
private CancellationTokenSource? _preparation;
private Task _preparationTask = Task.CompletedTask;
- private bool _preparedContacts, _requestedContacts;
private Point3D _target;
private Point _dragStart, _dragPrevious;
private bool _dragMoved;
@@ -65,11 +63,11 @@ public LapReviewTrack3D()
var ambient = new AmbientLight(Colors.White); ambient.Freeze();
_viewport.Children.Add(new ModelVisual3D { Content = ambient });
_viewport.Children.Add(_path);
- _cursor.Content = LapReviewTrackGeometry.CursorModel();
- _cursor.Transform = _cursorPosition;
- _viewport.Children.Add(_cursor);
+ _contactOverlay = new ContactOverlay(this) { IsHitTestVisible = false, ClipToBounds = true };
+ _contactOverlay.SetResourceReference(ContactOverlay.OutlineProperty, "InputBrush");
_status.SetResourceReference(TextBlock.ForegroundProperty, "MutedBrush");
Children.Add(_viewport);
+ Children.Add(_contactOverlay);
Children.Add(_status);
Loaded += (_, _) => RefreshScene();
Unloaded += (_, _) => CancelPreparation();
@@ -130,42 +128,43 @@ private static void DataChanged(DependencyObject sender, DependencyPropertyChang
protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
- if (e.Property == ShowContactsProperty) RefreshScene();
+ if (e.Property == ShowContactsProperty) _contactOverlay?.InvalidateVisual();
if (e.Property == FocusCues.ShowKeyboardFocusProperty) InvalidateVisual();
}
private static bool SameScene(LapReviewPlotData? a, LapReviewPlotData b) => a is not null &&
ReferenceEquals(a.Lap, b.Lap) && ReferenceEquals(a.Reference, b.Reference) && ReferenceEquals(a.Comparison, b.Comparison) &&
- ReferenceEquals(a.Contacts, b.Contacts) && a.Channel == b.Channel && a.SpeedUnit == b.SpeedUnit && a.TorqueUnit == b.TorqueUnit &&
+ a.Channel == b.Channel && a.SpeedUnit == b.SpeedUnit && a.TorqueUnit == b.TorqueUnit &&
a.SectionStart == b.SectionStart && a.SectionEnd == b.SectionEnd && a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit;
private void RefreshScene()
{
+ _contactOverlay.InvalidateVisual();
if ((!IsLoaded || !IsVisible) && !_testPreparation) return;
if (Data is not { Lap.Points.Length: > 1 } data)
{
- CancelPreparation(); _scene = null; _preparedData = null; _path.Content = null; _cursor.Content = null;
+ CancelPreparation(); _scene = null; _preparedData = null; _path.Content = null;
SetStatus("No recorded lap positions", false); return;
}
- if (_scene is not null && SameScene(_preparedData, data) && _preparedContacts == ShowContacts)
+ if (_scene is not null && SameScene(_preparedData, data))
{ CancelPreparation(); _path.Content = _scene.Model; SetStatus(string.Empty, false); UpdateCursor(); return; }
- if (_preparation is not null && SameScene(_requestedData, data) && _requestedContacts == ShowContacts) return;
+ if (_preparation is not null && SameScene(_requestedData, data)) return;
CancelPreparation();
var cancellation = new CancellationTokenSource(); _preparation = cancellation;
- _requestedData = data; _requestedContacts = ShowContacts;
- _path.Content = null; _cursor.Content = null;
+ _requestedData = data;
+ _path.Content = null;
SetStatus("Preparing 3D map…", true);
- _preparationTask = PrepareAsync(data, ShowContacts, cancellation);
+ _preparationTask = PrepareAsync(data, cancellation);
}
- private async Task PrepareAsync(LapReviewPlotData data, bool showContacts, CancellationTokenSource cancellation)
+ private async Task PrepareAsync(LapReviewPlotData data, CancellationTokenSource cancellation)
{
try
{
- var scene = await Task.Run(() => LapReviewTrackGeometry.Build(data, showContacts, cancellation.Token), cancellation.Token);
+ var scene = await Task.Run(() => LapReviewTrackGeometry.Build(data, cancellation.Token), cancellation.Token);
if (cancellation.IsCancellationRequested || !ReferenceEquals(_preparation, cancellation)) return;
var firstScene = _scene is null;
- _scene = scene; _preparedData = data; _preparedContacts = showContacts;
+ _scene = scene; _preparedData = data;
if (firstScene) ResetView();
_path.Content = scene.Model; SceneBuildCount++;
SetStatus(string.Empty, false); UpdateCursor(); UpdateCamera();
@@ -174,7 +173,7 @@ private async Task PrepareAsync(LapReviewPlotData data, bool showContacts, Cance
catch (Exception exception)
{
if (!ReferenceEquals(_preparation, cancellation)) return;
- _scene = null; _preparedData = null; _path.Content = null; _cursor.Content = null;
+ _scene = null; _preparedData = null; _path.Content = null;
HealthContextRecorder.Current.RecordBreadcrumb(HealthEventCode.LapReviewFailed, exception.HResult);
SetStatus("The 3D map could not be prepared. Switch to 2D or reopen this lap.", false);
}
@@ -195,17 +194,10 @@ private void SetStatus(string message, bool preparing)
{
SetValue(IsPreparingPropertyKey, preparing); SetValue(StatusTextPropertyKey, message);
_status.Text = message; _status.Visibility = message.Length == 0 ? Visibility.Collapsed : Visibility.Visible;
+ _contactOverlay.InvalidateVisual();
}
- private void UpdateCursor()
- {
- if (_scene is null || Data is not { Lap.Points.Length: > 0 } data) { _cursor.Content = null; return; }
- var position = data.Lap.Points[Math.Clamp(data.Cursor, 0, data.Lap.Points.Length - 1)].Position;
- if (!LapReviewTrackBounds.IsFinite(position)) { _cursor.Content = null; return; }
- _cursor.Content ??= LapReviewTrackGeometry.CursorModel();
- var p = _scene.Bounds.Normalize(position);
- _cursorPosition.OffsetX = p.X; _cursorPosition.OffsetY = p.Y; _cursorPosition.OffsetZ = p.Z;
- }
+ private void UpdateCursor() => _contactOverlay.InvalidateVisual();
private void UpdateCamera()
{
@@ -215,6 +207,7 @@ private void UpdateCamera()
_camera.LookDirection = frame.Forward;
_camera.UpDirection = frame.Up;
_camera.Width = (_scene?.Fit.Width(ActualWidth / Math.Max(1, ActualHeight)) ?? 1.5) / ZoomFactor;
+ _contactOverlay?.InvalidateVisual();
}
internal Task PrepareForTestAsync()
@@ -294,4 +287,37 @@ protected override void OnRender(DrawingContext dc)
if (IsKeyboardFocused && FocusCues.GetShowKeyboardFocus(this) && ActualWidth > 2 && ActualHeight > 2)
dc.DrawRectangle(null, new Pen(TryFindResource("TextBrush") as Brush ?? Brushes.White, 1), new Rect(1, 1, ActualWidth - 2, ActualHeight - 2));
}
+
+ private sealed class ContactOverlay(LapReviewTrack3D owner) : FrameworkElement
+ {
+ internal static readonly DependencyProperty OutlineProperty = DependencyProperty.Register(nameof(Outline), typeof(Brush), typeof(ContactOverlay),
+ new FrameworkPropertyMetadata(Brushes.Black, FrameworkPropertyMetadataOptions.AffectsRender));
+ private Brush Outline => (Brush)GetValue(OutlineProperty);
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ base.OnRender(dc);
+ if (!owner.IsReady || owner._scene is not { } scene || owner.Data is not { Lap: { } lap } data) return;
+ var camera = LapReviewCameraFrame.From(owner.Yaw, owner.Pitch, owner.Roll);
+ Point? Project(int index)
+ {
+ if ((uint)index >= (uint)lap.Points.Length || !LapReviewTrackBounds.IsFinite(lap.Points[index].Position)) return null;
+ var point = camera.Project(scene.Bounds.Normalize(lap.Points[index].Position), owner._target, owner._camera.Width, RenderSize);
+ return point.X < -10 || point.Y < -10 || point.X > ActualWidth + 10 || point.Y > ActualHeight + 10 ? null : point;
+ }
+ if (Project(0) is { } start)
+ {
+ dc.DrawEllipse(null, new Pen(Outline, 4), start, 6, 6);
+ dc.DrawEllipse(null, new Pen(LapReviewPalette.StartBrush, 2), start, 6, 6);
+ }
+ var cursorContact = owner.ShowContacts && data.Contacts?.Any(contact => contact.PointIndex == data.Cursor) == true;
+ if (Project(data.Cursor) is { } cursor)
+ dc.DrawEllipse(cursorContact ? null : Brushes.White,
+ new Pen(cursorContact ? Brushes.White : Outline, 1.5), cursor, cursorContact ? 10 : 4, cursorContact ? 10 : 4);
+ if (owner.ShowContacts && data.Contacts is { } contacts)
+ foreach (var contact in contacts)
+ if (Project(contact.PointIndex) is { } anchor)
+ LapReviewPalette.DrawContact(dc, anchor, Outline, contact.PointIndex == data.Cursor);
+ }
+ }
}
diff --git a/src/Wisp.App/Runs/LapReviewTrackGeometry.cs b/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
index b2dcf66..7bea642 100644
--- a/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
+++ b/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
@@ -163,7 +163,7 @@ internal static int[] RetainedIndices(LapReviewPoint[] points, Func Frames(LapReviewPoint[] points, (int F
return frames;
}
- internal static GeometryModel3D CursorModel()
- {
- var marker = new MeshBuilder(); marker.Diamond(new(), .008);
- var mesh = marker.Finish(); var material = new DiffuseMaterial(Brushes.White); material.Freeze();
- var result = new GeometryModel3D(mesh, material) { BackMaterial = material };
- result.Freeze(); return result;
- }
-
private static Brush Brush(byte r, byte g, byte b) { var brush = new SolidColorBrush(Color.FromRgb(r, g, b)); brush.Freeze(); return brush; }
private static void Add(Model3DGroup group, MeshBuilder builder, Brush brush)
{
@@ -320,14 +292,6 @@ internal void Tube(Point3D a, Point3D b, double radius, Vector3D? startDirection
for (var side = 0; side < sides; side++) _triangles.AddRange([center, offset + side * 2 + 1, offset + (side + 1) % sides * 2 + 1]);
}
}
- internal void Diamond(Point3D p, double radius)
- {
- var offset = _positions.Count;
- _positions.AddRange([p + new Vector3D(radius, 0, 0), p + new Vector3D(-radius, 0, 0),
- p + new Vector3D(0, radius, 0), p + new Vector3D(0, -radius, 0),
- p + new Vector3D(0, 0, radius), p + new Vector3D(0, 0, -radius)]);
- foreach (var i in new[] { 0, 2, 4, 4, 2, 1, 1, 2, 5, 5, 2, 0, 0, 4, 3, 4, 1, 3, 1, 5, 3, 5, 0, 3 }) _triangles.Add(offset + i);
- }
internal MeshGeometry3D Finish()
{
var mesh = new MeshGeometry3D { Positions = new Point3DCollection(_positions), TriangleIndices = new Int32Collection(_triangles) };
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index f1cf186..b723120 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -146,7 +146,7 @@
-
+
diff --git a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
index a20a246..65ed690 100644
--- a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
@@ -122,6 +122,51 @@ public void RenderedTubeColorMatchesTheLegendInsteadOfAddingItsFrontAndBackFaces
Assert.Equal(255, pixel[3]);
});
+ [Fact]
+ public void ContactAnnotationStaysAboveAnOccludingTrackAndFollowsTheCameraWithoutRebuilding() => OnSta(async () =>
+ {
+ // The raised return segment passes directly above the contact in a top view.
+ // Coloring by elevation proves the red foreground track really covers that position.
+ var data = Data([new(-100, 0, 0), new(0, 0, 0), new(100, 0, 0), new(100, 60, 0), new(-100, 60, 0)]) with
+ { Channel = LapReviewChannel.Elevation, Contacts = [new(1, 1, 100, LapReviewContactKind.PossibleContact)] };
+ var track = new LapReviewTrack3D { Data = data, ShowContacts = false };
+ var surface = new Border { Background = Brushes.Black, Child = track };
+ surface.Measure(new Size(800, 400)); surface.Arrange(new Rect(0, 0, 800, 400));
+ await track.PrepareForTestAsync();
+ track.Yaw = 0; track.Pitch = 90; track.Roll = 0;
+ var originalAnchor = track.ProjectPoint(1);
+ AssertPixel(surface, originalAnchor, ((SolidColorBrush)LapReviewPalette.GetBrush(1)).Color);
+ track.ShowContacts = true;
+ AssertPixel(surface, originalAnchor, ((SolidColorBrush)LapReviewPalette.ContactBrush).Color);
+
+ track.Yaw = 35; track.Pitch = 48; track.Roll = 14; track.ZoomBy(1.4); track.PanBy(22, -16);
+ var movedAnchor = track.ProjectPoint(1);
+ Assert.NotEqual(originalAnchor, movedAnchor);
+ AssertPixel(surface, movedAnchor, ((SolidColorBrush)LapReviewPalette.ContactBrush).Color);
+ track.Data = data with { Cursor = 1 };
+ AssertPixel(surface, movedAnchor, ((SolidColorBrush)LapReviewPalette.ContactBrush).Color);
+ track.Data = data with { Contacts = null };
+ track.ResetView(); track.Yaw = 0; track.Pitch = 90;
+ AssertPixel(surface, track.ProjectPoint(1), ((SolidColorBrush)LapReviewPalette.GetBrush(1)).Color);
+ track.Data = data;
+ AssertPixel(surface, track.ProjectPoint(1), ((SolidColorBrush)LapReviewPalette.ContactBrush).Color);
+ Assert.Equal(1, track.SceneBuildCount);
+ Assert.True(track.IsReady); Assert.False(track.IsPreparing);
+ });
+
+ private static void AssertPixel(FrameworkElement surface, Point anchor, Color expected)
+ {
+ surface.UpdateLayout();
+ var bitmap = new RenderTargetBitmap((int)surface.ActualWidth, (int)surface.ActualHeight, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(surface);
+ var pixel = new byte[4];
+ bitmap.CopyPixels(new Int32Rect((int)Math.Round(anchor.X), (int)Math.Round(anchor.Y), 1, 1), pixel, 4, 0);
+ Assert.InRange(Math.Abs(pixel[0] - expected.B), 0, 2);
+ Assert.InRange(Math.Abs(pixel[1] - expected.G), 0, 2);
+ Assert.InRange(Math.Abs(pixel[2] - expected.R), 0, 2);
+ Assert.Equal(255, pixel[3]);
+ }
+
private static LapReviewTrack3D Track()
{
var track = new LapReviewTrack3D { Data = Data([new(0, 0, 0), new(20, 10, 30), new(60, 15, 10)]) };
diff --git a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
index 1653d2c..2899610 100644
--- a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
@@ -140,7 +140,7 @@ public void DefaultCameraShowsAnElongatedTrackHorizontallyWithoutChangingElevati
public void AdjacentColoredSegmentsShareTheirEntireRingAtABend()
{
var data = Data([new(0, 0, 0), new(100, 10, 0), new(150, 30, 80)]);
- var scene = LapReviewTrackGeometry.Build(data, false, TestContext.Current.CancellationToken);
+ var scene = LapReviewTrackGeometry.Build(data, TestContext.Current.CancellationToken);
var color = ((SolidColorBrush)LapReviewPalette.GetBrush(.5)).Color;
var path = scene.Model.Children.OfType().Single(model =>
model.Material is DiffuseMaterial { Brush: SolidColorBrush brush } && brush.Color == color);
@@ -152,20 +152,20 @@ public void AdjacentColoredSegmentsShareTheirEntireRingAtABend()
public void BuiltGeometryIsFrozenAndCancelledPreparationCannotPublishAPartialScene()
{
var data = Data([new(0, 0, 0), new(100, 25, 50), new(200, 30, 100)]);
- var scene = LapReviewTrackGeometry.Build(data, true, CancellationToken.None);
+ var scene = LapReviewTrackGeometry.Build(data, CancellationToken.None);
Assert.True(scene.Model.IsFrozen); Assert.Equal(2, scene.SegmentCount); Assert.NotEmpty(scene.Model.Children);
- Assert.Throws(() => LapReviewTrackGeometry.Build(data, true, new CancellationToken(true)));
+ Assert.Throws(() => LapReviewTrackGeometry.Build(data, new CancellationToken(true)));
}
[Fact]
- public void MissingChannelStillDrawsTheRecordedPathAndContactsAreOptional()
+ public void MissingChannelStillDrawsTheRecordedPathAndContactsAreNotBakedIntoTheMesh()
{
var data = Data([new(0, 0, 0), new(100, 10, 50)]) with
{ Channel = LapReviewChannel.Delta, Contacts = [new(1, 1, 100, LapReviewContactKind.PossibleContact)] };
- var without = LapReviewTrackGeometry.Build(data, false, CancellationToken.None);
- var with = LapReviewTrackGeometry.Build(data, true, CancellationToken.None);
+ var without = LapReviewTrackGeometry.Build(data with { Contacts = null }, CancellationToken.None);
+ var with = LapReviewTrackGeometry.Build(data, CancellationToken.None);
Assert.Equal(1, without.SegmentCount);
- Assert.Equal(without.Model.Children.Count + 1, with.Model.Children.Count);
+ Assert.Equal(without.Model.Children.Count, with.Model.Children.Count);
}
private static LapReviewPlotData Data(LapPosition[] positions) => new(new LapReviewLap
From 4e86d1c2ea02d17f136d69f8f6a97550581a84b9 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 19:13:58 -0500
Subject: [PATCH 03/15] Apply standard keyboard focus styling to lap view mode
controls
---
src/Wisp.App/Runs/LapReviewView.xaml | 6 +++++-
tests/Wisp.App.Tests/CalmSidebarTests.cs | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index b723120..62bc8b1 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -1,6 +1,7 @@
@@ -22,7 +23,10 @@
-
+
+
+
+
diff --git a/tests/Wisp.App.Tests/CalmSidebarTests.cs b/tests/Wisp.App.Tests/CalmSidebarTests.cs
index 4adede3..93176c8 100644
--- a/tests/Wisp.App.Tests/CalmSidebarTests.cs
+++ b/tests/Wisp.App.Tests/CalmSidebarTests.cs
@@ -137,7 +137,7 @@ internal static void AssertOnCurrentDispatcher(MainWindow window, FrameworkEleme
Assert.Same(window.FindResource("CardStyle"), runLibrary.Style.BasedOn);
var runToggles = LogicalDescendants(runs).OfType().ToArray();
Assert.Equal(
- ["AutomaticRecording", "FullThrottleOnly", "HotkeyEnabled", "MarkerHotkeyEnabled", "SameSpeed"],
+ ["AutomaticRecording", "FullThrottleOnly", "HotkeyEnabled", "MarkerHotkeyEnabled", "SameSpeed", "ShowContacts"],
runToggles.Select(runToggle => Assert.IsType(
BindingOperations.GetBinding(runToggle, ToggleButton.IsCheckedProperty)).Path.Path)
.OrderBy(path => path, StringComparer.Ordinal));
From f41a4c46c34553c20c4e4dc6fe6ce799bd5ed39f Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 20:26:25 -0500
Subject: [PATCH 04/15] Add a shared 3D lap comparison workspace and improve
scrubbing
---
installer/PrivateShiftCaptureGuide.txt | 14 +-
src/Wisp.App/Runs/LapReviewPalette.cs | 1 +
src/Wisp.App/Runs/LapReviewPlot.cs | 41 ++-
.../Runs/LapReviewTrack3D.Comparison.cs | 108 ++++++++
src/Wisp.App/Runs/LapReviewTrack3D.cs | 198 ++++++++++----
src/Wisp.App/Runs/LapReviewTrackGeometry.cs | 84 +++++-
src/Wisp.App/Runs/LapReviewView.Scrubbing.cs | 49 ++++
src/Wisp.App/Runs/LapReviewView.Workspace.cs | 24 ++
src/Wisp.App/Runs/LapReviewView.xaml | 161 +++++++-----
src/Wisp.App/Runs/LapReviewView.xaml.cs | 11 +-
src/Wisp.App/Runs/LapReviewViewModel.Map.cs | 20 +-
.../Runs/LapReviewViewModel.MapComparison.cs | 71 +++++
src/Wisp.App/Runs/LapReviewViewModel.cs | 32 ++-
src/Wisp.App/Runs/RunsPage.xaml.cs | 1 +
src/Wisp.App/Runs/RunsPageBase.cs | 3 +-
.../LapReviewMapComparisonTests.cs | 243 ++++++++++++++++++
.../LapReviewPlotRenderingTests.cs | 131 ++++++++++
.../Wisp.App.Tests/LapReviewPlotValueTests.cs | 83 ++++++
.../Wisp.App.Tests/LapReviewScrubbingTests.cs | 96 +++++++
tests/Wisp.App.Tests/LapReviewTrack3DTests.cs | 151 +++++++++++
.../LapReviewTrackGeometryTests.cs | 38 ++-
tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs | 1 +
.../LapReview3DPerformanceReview.cs | 182 ++++++++++++-
tools/Wisp.UiReview/LapReviewUiChecks.cs | 181 ++++++++++++-
24 files changed, 1760 insertions(+), 164 deletions(-)
create mode 100644 src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs
create mode 100644 src/Wisp.App/Runs/LapReviewView.Scrubbing.cs
create mode 100644 src/Wisp.App/Runs/LapReviewView.Workspace.cs
create mode 100644 src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
create mode 100644 tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
create mode 100644 tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs
create mode 100644 tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index 17825fa..fe2ddbd 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -1,5 +1,5 @@
Wisp 2.6.4 Lap review 3D test
-Private build ID: lap-review-3d-20261006-01
+Private build ID: lap-review-3d-20261006-02
This is a private test build, not a public release. It updates your existing
Wisp installation and keeps your settings, profiles, calibrations, runs and
@@ -13,12 +13,22 @@ Changes
- Read the color legend and the selected point's value next to the map.
- Show reported object contacts, possible impacts and saved manual markers.
- Save the current 2D or 3D map, including its legend, as a PNG image.
+- Compare two 3D laps in one continuous workspace, using the same channel and
+ color scale. Click a track to move closer; zoom back out to see both.
+- Keep the lap-position slider above the map and batch drag updates to one
+ per UI render cycle, preserving the final position when released.
Controls
Drag: rotate. Shift-drag or right-drag: pan. Ctrl-drag: roll.
-Scroll: zoom. Double-click or Reset view: restore the camera.
+Scroll: zoom. Double-click or Reset camera: restore the workspace camera.
Click the line: select a point. Arrow keys: move along the lap.
Home/End: first/last point. Camera controls also provide axis sliders.
+Choose Run B using Compare above, or choose a reference in Lap settings and
+benchmark. In 3D, both tracks remain in the same scene, laid out side by side.
+Click a track to bring the camera toward it. Scroll back out, choose Show
+both laps or press Escape to frame both again. Rotation, pan and axis
+controls affect the shared camera. PNG saves the current camera view.
+Contact editing is labelled Run A; each map shows its own saved markers.
Suggested checks
1. Open an existing saved lap in Runs and expand Lap review.
diff --git a/src/Wisp.App/Runs/LapReviewPalette.cs b/src/Wisp.App/Runs/LapReviewPalette.cs
index 6e6eb06..16e002d 100644
--- a/src/Wisp.App/Runs/LapReviewPalette.cs
+++ b/src/Wisp.App/Runs/LapReviewPalette.cs
@@ -66,6 +66,7 @@ internal readonly record struct LapReviewColorRange(double Minimum, double Maxim
internal double Fraction(double value) => Maximum > Minimum ? Math.Clamp((value - Minimum) / (Maximum - Minimum), 0, 1) : .5;
internal static LapReviewColorRange From(LapReviewPlotData data)
{
+ if (data.ColorRangeOverride is { } shared) return shared;
var minimum = double.PositiveInfinity; var maximum = double.NegativeInfinity;
if (data.Lap is { } lap)
for (var i = 0; i < lap.Points.Length; i++)
diff --git a/src/Wisp.App/Runs/LapReviewPlot.cs b/src/Wisp.App/Runs/LapReviewPlot.cs
index 247c664..0e0bb55 100644
--- a/src/Wisp.App/Runs/LapReviewPlot.cs
+++ b/src/Wisp.App/Runs/LapReviewPlot.cs
@@ -10,7 +10,16 @@ namespace Wisp.App.Runs;
public sealed record LapReviewPlotData(LapReviewLap? Lap, LapReviewLap? Reference,
LapReviewComparison? Comparison, LapReviewChannel Channel, SpeedUnit SpeedUnit, int Cursor, int SectionStart, int SectionEnd,
int Wheel = 0, TireTemperatureUnit TemperatureUnit = TireTemperatureUnit.Fahrenheit,
- TorqueUnit TorqueUnit = TorqueUnit.NewtonMeters, IReadOnlyList? Contacts = null);
+ TorqueUnit TorqueUnit = TorqueUnit.NewtonMeters, IReadOnlyList? Contacts = null)
+{
+ public bool ShowReferencePath { get; init; } = true;
+ internal LapReviewColorRange? ColorRangeOverride { get; init; }
+ internal LapPosition? CursorPositionOverride { get; init; }
+ public bool HasDistinctReference => Comparison?.CanCompare == true &&
+ Lap is { Points.Length: > 0 } lap && Reference is { Points.Length: > 0 } reference &&
+ (lap.RunId != reference.RunId || lap.TimingMode != reference.TimingMode || lap.Number != reference.Number ||
+ lap.Points[0].SampleIndex != reference.Points[0].SampleIndex);
+}
public sealed class LapReviewPlot : FrameworkElement
{
@@ -58,7 +67,8 @@ protected override void OnRender(DrawingContext dc)
private static bool SamePlot(LapReviewPlotData? a, LapReviewPlotData b) => a is not null &&
ReferenceEquals(a.Lap, b.Lap) && ReferenceEquals(a.Reference, b.Reference) && ReferenceEquals(a.Comparison, b.Comparison) &&
a.Channel == b.Channel && a.SpeedUnit == b.SpeedUnit && a.SectionStart == b.SectionStart && a.SectionEnd == b.SectionEnd &&
- a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit && a.TorqueUnit == b.TorqueUnit && ReferenceEquals(a.Contacts, b.Contacts);
+ a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit && a.TorqueUnit == b.TorqueUnit && ReferenceEquals(a.Contacts, b.Contacts) &&
+ a.ShowReferencePath == b.ShowReferencePath && a.ColorRangeOverride == b.ColorRangeOverride;
private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Brush background)
{
_hitPoints.Clear();
@@ -69,6 +79,8 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
if (!IsMap) values = values.Concat(Enumerable.Range(0, points.Length).Select(index => ReferenceValue(data, index)));
var finite = values.Where(value => value is { } number && double.IsFinite(number)).Select(value => value!.Value).ToArray();
var minimum = finite.Length == 0 ? 0 : finite.Min(); var maximum = finite.Length == 0 ? 1 : finite.Max();
+ if (IsMap && data.ColorRangeOverride is { HasValues: true } range)
+ { minimum = range.Minimum; maximum = range.Maximum; }
var colorMinimum = minimum; var colorMaximum = maximum;
var constant = maximum == minimum;
if (maximum - minimum < .001) maximum = minimum + 1;
@@ -76,7 +88,7 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
if (IsMap)
{
var all = points.AsEnumerable();
- if (data.Comparison?.CanCompare == true && data.Reference is { } reference) all = all.Concat(reference.Points);
+ if (data.HasDistinctReference && data.Reference is { } reference) all = all.Concat(reference.Points);
var array = all.ToArray();
var left = array.Min(point => point.Position.X); var right = array.Max(point => point.Position.X);
var bottom = array.Min(point => point.Position.Z); var top = array.Max(point => point.Position.Z);
@@ -86,7 +98,7 @@ private void Prepare(DrawingContext dc, LapReviewPlotData data, Brush muted, Bru
project = point => new(ox + (point.Position.X - left) * scale, oy + (top - point.Position.Z) * scale);
}
else project = point => new(area.Left + point.DistanceMeters / Math.Max(1, data.Lap.RecordedDistanceMeters) * area.Width, area.Bottom);
- if (IsMap && data.Comparison?.CanCompare == true && data.Reference is { } mapReference)
+ if (IsMap && data.ShowReferencePath && data.HasDistinctReference && data.Reference is { } mapReference)
DrawPath(dc, mapReference.Points, project, new(RunComparisonColors.RunB, 1.5));
var stride = Math.Max(1, (int)Math.Ceiling(points.Length / 3000d));
Point? prior = null; var broken = false;
@@ -141,6 +153,27 @@ private static void DrawPath(DrawingContext dc, LapReviewPoint[] points, Func= comparison.Points.Length) return null;
+ var match = comparison.Points[index];
+ if (match.ReferencePointIndex is not { } lower || lower < 0 || lower >= reference.Points.Length ||
+ match.ReferenceLapSeconds is not { } seconds || !double.IsFinite(seconds)) return null;
+ var a = reference.Points[lower];
+ if (!Finite(a.Position) || !double.IsFinite(a.LapSeconds)) return null;
+ if (seconds == a.LapSeconds) return a.Position;
+ if (lower + 1 >= reference.Points.Length) return null;
+ var b = reference.Points[lower + 1];
+ if (b.BreakBefore || !Finite(b.Position) || !double.IsFinite(b.LapSeconds) ||
+ b.LapSeconds <= a.LapSeconds || seconds < a.LapSeconds || seconds > b.LapSeconds) return null;
+ var fraction = (seconds - a.LapSeconds) / (b.LapSeconds - a.LapSeconds);
+ return new((float)(a.Position.X + ((double)b.Position.X - a.Position.X) * fraction),
+ (float)(a.Position.Y + ((double)b.Position.Y - a.Position.Y) * fraction),
+ (float)(a.Position.Z + ((double)b.Position.Z - a.Position.Z) * fraction));
+
+ static bool Finite(LapPosition position) => float.IsFinite(position.X) && float.IsFinite(position.Y) && float.IsFinite(position.Z);
+ }
internal static double? ReferenceValue(LapReviewPlotData data, int index)
{
if (data.Channel == LapReviewChannel.Delta || data.Comparison?.CanCompare != true || data.Reference is not { } reference) return null;
diff --git a/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs b/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs
new file mode 100644
index 0000000..719c45c
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs
@@ -0,0 +1,108 @@
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Media3D;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewTrack3D
+{
+ public static readonly DependencyProperty ComparisonDataProperty = DependencyProperty.Register(nameof(ComparisonData),
+ typeof(LapReviewPlotData), typeof(LapReviewTrack3D), new FrameworkPropertyMetadata(null, DataChanged));
+ private static readonly DependencyPropertyKey IsComparisonPropertyKey = DependencyProperty.RegisterReadOnly(nameof(IsComparison),
+ typeof(bool), typeof(LapReviewTrack3D), new PropertyMetadata(false));
+ public static readonly DependencyProperty IsComparisonProperty = IsComparisonPropertyKey.DependencyProperty;
+ private static readonly DependencyPropertyKey FocusedLapPropertyKey = DependencyProperty.RegisterReadOnly(nameof(FocusedLap),
+ typeof(int), typeof(LapReviewTrack3D), new PropertyMetadata(0));
+ public static readonly DependencyProperty FocusedLapProperty = FocusedLapPropertyKey.DependencyProperty;
+
+ public LapReviewPlotData? ComparisonData { get => (LapReviewPlotData?)GetValue(ComparisonDataProperty); set => SetValue(ComparisonDataProperty, value); }
+ public bool IsComparison => (bool)GetValue(IsComparisonProperty);
+ public int FocusedLap => (int)GetValue(FocusedLapProperty);
+ public event Action? ReferencePointChosen;
+
+ private readonly ModelVisual3D _comparisonPath = new();
+ private readonly TranslateTransform3D _primaryPosition = new(), _referencePosition = new();
+ private LapReviewTrackScene? _comparisonScene;
+ private LapReviewPlotData? _preparedComparison, _requestedComparison;
+ private LapReviewTrackArrangement? _arrangement;
+ private bool _resetOverview, _cameraMotionActive, _updatingCameraMotion;
+ private long _cameraMotionStarted;
+ private Point3D _motionStartTarget, _motionEndTarget;
+ private double _motionStartZoom, _motionEndZoom;
+ private LapReviewPlotData? EffectiveComparison => Data?.HasDistinctReference == true && ComparisonData is { Lap.Points.Length: > 1 } data ? data : null;
+ internal int PreparedReferenceSegmentCount => _comparisonScene?.SegmentCount ?? 0;
+ internal bool HasBothPreparedModels => _path.Content is not null && _comparisonPath.Content is not null;
+ internal bool IsCameraMotionActive => _cameraMotionActive;
+ private LapReviewTrackFit? CurrentOverviewFit => IsComparison && _arrangement is { } arrangement
+ ? arrangement.ProjectedFit(Yaw, Pitch, Roll) : _arrangement?.Overview;
+
+ private static bool SameOptionalScene(LapReviewPlotData? a, LapReviewPlotData? b) =>
+ a is null ? b is null : b is not null && SameScene(a, b);
+
+ public void FocusLap(int lap)
+ {
+ if (!IsReady || !IsComparison || _arrangement is not { } arrangement || lap is < 1 or > 2) return;
+ var selected = arrangement.ProjectedFit(Yaw, Pitch, Roll, lap);
+ SetValue(FocusedLapPropertyKey, lap);
+ var aspect = ActualWidth / Math.Max(1, ActualHeight);
+ AnimateCamera(arrangement.ProjectedFit(Yaw, Pitch, Roll, lap).Target,
+ Math.Clamp(arrangement.ProjectedFit(Yaw, Pitch, Roll).Width(aspect) / selected.Width(aspect), 1.8, 50));
+ }
+
+ public void ShowAll()
+ {
+ if (_arrangement is not { } arrangement) return;
+ SetValue(FocusedLapPropertyKey, 0);
+ AnimateCamera(IsComparison ? arrangement.ProjectedFit(Yaw, Pitch, Roll).Target : arrangement.Overview.Target, 1);
+ }
+
+ private void AnimateCamera(Point3D target, double zoom)
+ {
+ StopCameraMotion();
+ _motionStartTarget = _target; _motionEndTarget = target;
+ _motionStartZoom = ZoomFactor; _motionEndZoom = zoom;
+ _cameraMotionStarted = Environment.TickCount64;
+ _cameraMotionActive = true;
+ CompositionTarget.Rendering += RenderCameraMotion;
+ }
+
+ private void RenderCameraMotion(object? sender, EventArgs args)
+ {
+ var progress = Math.Clamp((Environment.TickCount64 - _cameraMotionStarted) / 180d, 0, 1);
+ ApplyCameraMotion(1 - Math.Pow(1 - progress, 3));
+ if (progress >= 1) StopCameraMotion();
+ }
+
+ private void ApplyCameraMotion(double fraction)
+ {
+ _updatingCameraMotion = true;
+ try
+ {
+ _target = _motionStartTarget + (_motionEndTarget - _motionStartTarget) * fraction;
+ SetCurrentValue(ZoomFactorProperty, _motionStartZoom + (_motionEndZoom - _motionStartZoom) * fraction);
+ UpdateCamera();
+ }
+ finally { _updatingCameraMotion = false; }
+ }
+
+ private void StopCameraMotion()
+ {
+ if (!_cameraMotionActive) return;
+ CompositionTarget.Rendering -= RenderCameraMotion;
+ _cameraMotionActive = false;
+ }
+
+ internal void CompleteCameraMotionForTest()
+ {
+ if (!_cameraMotionActive) return;
+ ApplyCameraMotion(1);
+ StopCameraMotion();
+ }
+
+ internal Point ProjectReferencePoint(int index)
+ {
+ if (_comparisonScene is null || EffectiveComparison?.Lap is not { } lap || (uint)index >= (uint)lap.Points.Length) return new(double.NaN, double.NaN);
+ return LapReviewCameraFrame.From(Yaw, Pitch, Roll).Project(_comparisonScene.Bounds.Normalize(lap.Points[index].Position) +
+ (_arrangement?.ReferenceOffset ?? new()), _target, _camera.Width, RenderSize);
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewTrack3D.cs b/src/Wisp.App/Runs/LapReviewTrack3D.cs
index ea6cc7d..540da3c 100644
--- a/src/Wisp.App/Runs/LapReviewTrack3D.cs
+++ b/src/Wisp.App/Runs/LapReviewTrack3D.cs
@@ -7,7 +7,7 @@
namespace Wisp.App.Runs;
-public sealed class LapReviewTrack3D : Grid
+public sealed partial class LapReviewTrack3D : Grid
{
public static readonly DependencyProperty DataProperty = DependencyProperty.Register(nameof(Data), typeof(LapReviewPlotData), typeof(LapReviewTrack3D),
new FrameworkPropertyMetadata(null, DataChanged));
@@ -33,8 +33,12 @@ public sealed class LapReviewTrack3D : Grid
public bool IsPreparing => (bool)GetValue(IsPreparingProperty);
public string StatusText => (string)GetValue(StatusTextProperty);
public bool IsReady => _scene is not null && _path.Content is not null && Data is { } data &&
- SameScene(_preparedData, data) && !IsPreparing && string.IsNullOrEmpty(StatusText);
+ SameScene(_preparedData, data) && SameOptionalScene(_preparedComparison, EffectiveComparison) &&
+ (EffectiveComparison is null || _comparisonScene is not null && _comparisonPath.Content is not null) &&
+ !IsPreparing && string.IsNullOrEmpty(StatusText);
public event Action? PointChosen;
+ public event EventHandler? FocusRequested;
+ public event EventHandler? ZoomOutAtOverview;
private readonly Viewport3D _viewport = new() { IsHitTestVisible = false, ClipToBounds = true };
private readonly OrthographicCamera _camera = new() { NearPlaneDistance = .01, FarPlaneDistance = 20 };
@@ -63,6 +67,9 @@ public LapReviewTrack3D()
var ambient = new AmbientLight(Colors.White); ambient.Freeze();
_viewport.Children.Add(new ModelVisual3D { Content = ambient });
_viewport.Children.Add(_path);
+ _path.Transform = _primaryPosition;
+ _comparisonPath.Transform = _referencePosition;
+ _viewport.Children.Add(_comparisonPath);
_contactOverlay = new ContactOverlay(this) { IsHitTestVisible = false, ClipToBounds = true };
_contactOverlay.SetResourceReference(ContactOverlay.OutlineProperty, "InputBrush");
_status.SetResourceReference(TextBlock.ForegroundProperty, "MutedBrush");
@@ -70,30 +77,49 @@ public LapReviewTrack3D()
Children.Add(_contactOverlay);
Children.Add(_status);
Loaded += (_, _) => RefreshScene();
- Unloaded += (_, _) => CancelPreparation();
- IsVisibleChanged += (_, _) => { if (IsVisible) RefreshScene(); else CancelPreparation(); };
+ Unloaded += (_, _) => { CancelPreparation(); StopCameraMotion(); };
+ IsVisibleChanged += (_, _) => { if (IsVisible) RefreshScene(); else { CancelPreparation(); StopCameraMotion(); } };
SizeChanged += (_, _) => UpdateCamera();
UpdateCamera();
}
public void ResetView()
{
- _target = _scene?.Fit.Target ?? new();
- SetCurrentValue(YawProperty, _scene?.Fit.Yaw ?? 0d);
+ StopCameraMotion();
+ _updatingCameraMotion = true;
+ _target = _arrangement?.Overview.Target ?? _scene?.Fit.Target ?? new();
+ SetCurrentValue(YawProperty, _arrangement?.Overview.Yaw ?? _scene?.Fit.Yaw ?? 0d);
SetCurrentValue(PitchProperty, LapReviewTrackFit.DefaultPitch);
SetCurrentValue(RollProperty, 0d);
SetCurrentValue(ZoomFactorProperty, 1d);
+ if (IsComparison && _arrangement is { } arrangement) _target = arrangement.ProjectedFit(Yaw, Pitch, Roll).Target;
+ _updatingCameraMotion = false;
+ SetValue(FocusedLapPropertyKey, 0);
UpdateCamera();
}
public void ZoomBy(double factor)
{
- if (double.IsFinite(factor) && factor > 0) SetCurrentValue(ZoomFactorProperty, ZoomFactor * factor);
+ if (!double.IsFinite(factor) || factor <= 0) return;
+ StopCameraMotion();
+ var previous = ZoomFactor;
+ var next = Math.Clamp(previous * factor, .2, 50);
+ if (factor < 1 && IsComparison && _arrangement is { } arrangement)
+ {
+ var fraction = previous > 1 ? Math.Clamp((next - 1) / (previous - 1), 0, 1) : 0;
+ var overview = arrangement.ProjectedFit(Yaw, Pitch, Roll).Target;
+ _target = overview + (_target - overview) * fraction;
+ if (next <= 1) SetValue(FocusedLapPropertyKey, 0);
+ }
+ SetCurrentValue(ZoomFactorProperty, next);
+ UpdateCamera();
+ if (factor < 1 && ZoomFactor <= 1) ZoomOutAtOverview?.Invoke(this, EventArgs.Empty);
}
public void PanBy(double horizontalPixels, double verticalPixels)
{
if (!double.IsFinite(horizontalPixels) || !double.IsFinite(verticalPixels)) return;
+ StopCameraMotion();
var frame = LapReviewCameraFrame.From(Yaw, Pitch, Roll);
var scale = _camera.Width / Math.Max(1, ActualWidth);
var target = _target - frame.Right * horizontalPixels * scale + frame.Up * verticalPixels * scale;
@@ -112,15 +138,27 @@ private static double CoerceAngle(double value, double initial, bool pitch)
var normalized = value % 360;
return normalized > 180 ? normalized - 360 : normalized < -180 ? normalized + 360 : normalized;
}
- private static void CameraChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => ((LapReviewTrack3D)sender).UpdateCamera();
+ private static void CameraChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
+ {
+ var track = (LapReviewTrack3D)sender;
+ if (!track._updatingCameraMotion) track.StopCameraMotion();
+ track.UpdateCamera();
+ }
private static void DataChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
var track = (LapReviewTrack3D)sender;
var previous = (LapReviewPlotData?)args.OldValue; var next = (LapReviewPlotData?)args.NewValue;
+ if (track.IsReady)
+ {
+ track.UpdateCursor();
+ return;
+ }
+ track.StopCameraMotion();
if (!ReferenceEquals(previous?.Lap, next?.Lap))
{
- track._scene = null; track._preparedData = null;
- track.ResetView();
+ if (args.Property == DataProperty) { track._scene = null; track._preparedData = null; }
+ else { track._comparisonScene = null; track._preparedComparison = null; }
+ track._resetOverview = true;
}
track.RefreshScene();
}
@@ -135,58 +173,83 @@ protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
private static bool SameScene(LapReviewPlotData? a, LapReviewPlotData b) => a is not null &&
ReferenceEquals(a.Lap, b.Lap) && ReferenceEquals(a.Reference, b.Reference) && ReferenceEquals(a.Comparison, b.Comparison) &&
a.Channel == b.Channel && a.SpeedUnit == b.SpeedUnit && a.TorqueUnit == b.TorqueUnit &&
+ a.ShowReferencePath == b.ShowReferencePath && a.ColorRangeOverride == b.ColorRangeOverride &&
a.SectionStart == b.SectionStart && a.SectionEnd == b.SectionEnd && a.Wheel == b.Wheel && a.TemperatureUnit == b.TemperatureUnit;
private void RefreshScene()
{
_contactOverlay.InvalidateVisual();
+ if (EffectiveComparison is null) { SetValue(IsComparisonPropertyKey, false); SetValue(FocusedLapPropertyKey, 0); }
if ((!IsLoaded || !IsVisible) && !_testPreparation) return;
if (Data is not { Lap.Points.Length: > 1 } data)
{
- CancelPreparation(); _scene = null; _preparedData = null; _path.Content = null;
+ CancelPreparation(); _scene = _comparisonScene = null; _preparedData = _preparedComparison = null;
+ _arrangement = null; _path.Content = _comparisonPath.Content = null;
+ SetValue(IsComparisonPropertyKey, false); SetValue(FocusedLapPropertyKey, 0);
SetStatus("No recorded lap positions", false); return;
}
- if (_scene is not null && SameScene(_preparedData, data))
- { CancelPreparation(); _path.Content = _scene.Model; SetStatus(string.Empty, false); UpdateCursor(); return; }
- if (_preparation is not null && SameScene(_requestedData, data)) return;
+ var other = EffectiveComparison;
+ if (_scene is not null && SameScene(_preparedData, data) && SameOptionalScene(_preparedComparison, other))
+ {
+ CancelPreparation(); _path.Content = _scene.Model; _comparisonPath.Content = other is null ? null : _comparisonScene?.Model;
+ SetStatus(string.Empty, false); UpdateCursor(); return;
+ }
+ if (_preparation is not null && SameScene(_requestedData, data) && SameOptionalScene(_requestedComparison, other)) return;
CancelPreparation();
var cancellation = new CancellationTokenSource(); _preparation = cancellation;
_requestedData = data;
- _path.Content = null;
+ _requestedComparison = other;
+ _path.Content = _comparisonPath.Content = null;
SetStatus("Preparing 3D map…", true);
- _preparationTask = PrepareAsync(data, cancellation);
+ _preparationTask = PrepareAsync(data, other, cancellation);
}
- private async Task PrepareAsync(LapReviewPlotData data, CancellationTokenSource cancellation)
+ private async Task PrepareAsync(LapReviewPlotData data, LapReviewPlotData? other, CancellationTokenSource cancellation)
{
try
{
- var scene = await Task.Run(() => LapReviewTrackGeometry.Build(data, cancellation.Token), cancellation.Token);
+ var cachedA = SameScene(_preparedData, data) ? _scene : null;
+ var cachedB = other is not null && SameScene(_preparedComparison, other) ? _comparisonScene : null;
+ var result = await Task.Run(() =>
+ {
+ var bounds = LapReviewTrackBounds.From(data, other?.Lap);
+ var buildA = cachedA?.Bounds != bounds;
+ var buildB = other is not null && cachedB?.Bounds != bounds;
+ var scene = buildA ? LapReviewTrackGeometry.Build(data, bounds, cancellation.Token) : cachedA!;
+ var comparison = other is null ? null : buildB ? LapReviewTrackGeometry.Build(other, bounds, cancellation.Token) : cachedB;
+ return (scene, comparison, arrangement: LapReviewTrackArrangement.Create(data, other, bounds), builds: (buildA ? 1 : 0) + (buildB ? 1 : 0));
+ }, cancellation.Token);
if (cancellation.IsCancellationRequested || !ReferenceEquals(_preparation, cancellation)) return;
- var firstScene = _scene is null;
- _scene = scene; _preparedData = data;
- if (firstScene) ResetView();
- _path.Content = scene.Model; SceneBuildCount++;
+ var reset = _arrangement is null || _resetOverview;
+ _scene = result.scene; _comparisonScene = result.comparison;
+ _preparedData = data; _preparedComparison = other; _arrangement = result.arrangement;
+ _primaryPosition.OffsetX = _arrangement.PrimaryOffset.X; _primaryPosition.OffsetY = _arrangement.PrimaryOffset.Y; _primaryPosition.OffsetZ = _arrangement.PrimaryOffset.Z;
+ _referencePosition.OffsetX = _arrangement.ReferenceOffset.X; _referencePosition.OffsetY = _arrangement.ReferenceOffset.Y; _referencePosition.OffsetZ = _arrangement.ReferenceOffset.Z;
+ _path.Content = _scene.Model; _comparisonPath.Content = _comparisonScene?.Model; SceneBuildCount += result.builds;
+ SetValue(IsComparisonPropertyKey, other is not null);
+ _resetOverview = false;
+ if (reset) ResetView();
SetStatus(string.Empty, false); UpdateCursor(); UpdateCamera();
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { }
catch (Exception exception)
{
if (!ReferenceEquals(_preparation, cancellation)) return;
- _scene = null; _preparedData = null; _path.Content = null;
+ _scene = _comparisonScene = null; _preparedData = _preparedComparison = null; _path.Content = _comparisonPath.Content = null;
+ _arrangement = null; SetValue(IsComparisonPropertyKey, false);
HealthContextRecorder.Current.RecordBreadcrumb(HealthEventCode.LapReviewFailed, exception.HResult);
SetStatus("The 3D map could not be prepared. Switch to 2D or reopen this lap.", false);
}
finally
{
- if (ReferenceEquals(_preparation, cancellation)) { _preparation = null; _requestedData = null; }
+ if (ReferenceEquals(_preparation, cancellation)) { _preparation = null; _requestedData = _requestedComparison = null; }
cancellation.Dispose();
}
}
private void CancelPreparation()
{
- _preparation?.Cancel(); _preparation = null; _requestedData = null;
+ _preparation?.Cancel(); _preparation = null; _requestedData = _requestedComparison = null;
if (IsPreparing) SetStatus(string.Empty, false);
}
@@ -206,7 +269,7 @@ private void UpdateCamera()
_camera.Position = _target - frame.Forward * 4;
_camera.LookDirection = frame.Forward;
_camera.UpDirection = frame.Up;
- _camera.Width = (_scene?.Fit.Width(ActualWidth / Math.Max(1, ActualHeight)) ?? 1.5) / ZoomFactor;
+ _camera.Width = (CurrentOverviewFit?.Width(ActualWidth / Math.Max(1, ActualHeight)) ?? _scene?.Fit.Width(ActualWidth / Math.Max(1, ActualHeight)) ?? 1.5) / ZoomFactor;
_contactOverlay?.InvalidateVisual();
}
@@ -220,22 +283,30 @@ internal Task PrepareForTestAsync()
internal Point ProjectPoint(int index)
{
if (_scene is null || Data?.Lap is not { } lap || (uint)index >= (uint)lap.Points.Length) return new(double.NaN, double.NaN);
- return LapReviewCameraFrame.From(Yaw, Pitch, Roll).Project(_scene.Bounds.Normalize(lap.Points[index].Position), _target, _camera.Width, RenderSize);
+ return LapReviewCameraFrame.From(Yaw, Pitch, Roll).Project(_scene.Bounds.Normalize(lap.Points[index].Position) + (_arrangement?.PrimaryOffset ?? new()), _target, _camera.Width, RenderSize);
}
- private void Choose(Point click)
+ internal void Choose(Point click)
{
if (!IsReady || Data?.Lap is not { } lap || _scene is null) return;
var frame = LapReviewCameraFrame.From(Yaw, Pitch, Roll);
- var closest = double.PositiveInfinity; var index = -1;
- for (var i = 0; i < lap.Points.Length; i++)
+ var closest = double.PositiveInfinity; var index = -1; var selectedLap = 1;
+ void Search(Wisp.Core.Runs.LapReviewLap candidate, LapReviewTrackScene scene, Vector3D offset, int number)
{
- if (!LapReviewTrackBounds.IsFinite(lap.Points[i].Position)) continue;
- var p = frame.Project(_scene.Bounds.Normalize(lap.Points[i].Position), _target, _camera.Width, RenderSize);
- var distance = (p - click).LengthSquared;
- if (distance < closest) { closest = distance; index = i; }
+ for (var i = 0; i < candidate.Points.Length; i++)
+ {
+ if (!LapReviewTrackBounds.IsFinite(candidate.Points[i].Position)) continue;
+ var p = frame.Project(scene.Bounds.Normalize(candidate.Points[i].Position) + offset, _target, _camera.Width, RenderSize);
+ var distance = (p - click).LengthSquared;
+ if (distance < closest) { closest = distance; index = i; selectedLap = number; }
+ }
}
- if (index >= 0) PointChosen?.Invoke(index);
+ Search(lap, _scene, _arrangement?.PrimaryOffset ?? new(), 1);
+ if (_comparisonScene is { } referenceScene && EffectiveComparison?.Lap is { } reference)
+ Search(reference, referenceScene, _arrangement?.ReferenceOffset ?? new(), 2);
+ if (index < 0) return;
+ if (selectedLap == 2) ReferencePointChosen?.Invoke(index); else PointChosen?.Invoke(index);
+ if (IsComparison && selectedLap != FocusedLap) FocusLap(selectedLap);
}
protected override void OnMouseDown(MouseButtonEventArgs e)
@@ -264,7 +335,11 @@ protected override void OnMouseUp(MouseButtonEventArgs e)
{
base.OnMouseUp(e);
if (!IsMouseCaptured || e.ChangedButton != _dragButton) return;
- if (!_dragMoved && e.ChangedButton == MouseButton.Left) Choose(e.GetPosition(this));
+ if (!_dragMoved && e.ChangedButton == MouseButton.Left)
+ {
+ Choose(e.GetPosition(this));
+ if (IsReady) FocusRequested?.Invoke(this, EventArgs.Empty);
+ }
ReleaseMouseCapture(); e.Handled = true;
}
protected override void OnMouseWheel(MouseWheelEventArgs e) { base.OnMouseWheel(e); ZoomBy(Math.Pow(1.15, e.Delta / 120d)); e.Handled = true; }
@@ -274,10 +349,13 @@ protected override void OnKeyDown(KeyEventArgs e)
if (e.Key == Key.R) { ResetView(); e.Handled = true; return; }
if (e.Key is Key.Add or Key.OemPlus) { ZoomBy(1.2); e.Handled = true; return; }
if (e.Key is Key.Subtract or Key.OemMinus) { ZoomBy(1 / 1.2); e.Handled = true; return; }
- if (Data?.Lap is not { Points.Length: > 0 } lap) return;
- var next = e.Key switch { Key.Left or Key.Down => Data.Cursor - 1, Key.Right or Key.Up => Data.Cursor + 1, Key.Home => 0, Key.End => lap.Points.Length - 1, _ => -1 };
+ var active = FocusedLap == 2 ? EffectiveComparison : Data;
+ if (active?.Lap is not { Points.Length: > 0 } lap) return;
+ var next = e.Key switch { Key.Left or Key.Down => active.Cursor - 1, Key.Right or Key.Up => active.Cursor + 1, Key.Home => 0, Key.End => lap.Points.Length - 1, _ => -1 };
if (next < 0 && e.Key is not Key.Left and not Key.Down) return;
- PointChosen?.Invoke(Math.Clamp(next, 0, lap.Points.Length - 1)); e.Handled = true;
+ var selected = Math.Clamp(next, 0, lap.Points.Length - 1);
+ if (FocusedLap == 2) ReferencePointChosen?.Invoke(selected); else PointChosen?.Invoke(selected);
+ e.Handled = true;
}
protected override void OnGotKeyboardFocus(KeyboardFocusChangedEventArgs e) { base.OnGotKeyboardFocus(e); InvalidateVisual(); }
protected override void OnLostKeyboardFocus(KeyboardFocusChangedEventArgs e) { base.OnLostKeyboardFocus(e); InvalidateVisual(); }
@@ -297,27 +375,55 @@ private sealed class ContactOverlay(LapReviewTrack3D owner) : FrameworkElement
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
- if (!owner.IsReady || owner._scene is not { } scene || owner.Data is not { Lap: { } lap } data) return;
+ if (!owner.IsReady || owner._scene is not { } scene || owner.Data is not { } data) return;
+ DrawLap(dc, data, scene, owner._arrangement?.PrimaryOffset ?? new(), owner.IsComparison ? "A" : null, owner._arrangement?.PrimaryFit);
+ if (owner._comparisonScene is { } referenceScene && owner.EffectiveComparison is { } reference)
+ DrawLap(dc, reference, referenceScene, owner._arrangement?.ReferenceOffset ?? new(), "B", owner._arrangement?.ReferenceFit);
+ }
+
+ private void DrawLap(DrawingContext dc, LapReviewPlotData data, LapReviewTrackScene scene, Vector3D offset, string? label, LapReviewTrackFit? fit)
+ {
+ if (data.Lap is not { } lap) return;
var camera = LapReviewCameraFrame.From(owner.Yaw, owner.Pitch, owner.Roll);
- Point? Project(int index)
+ Point? ProjectPosition(Wisp.Core.LapPosition position)
{
- if ((uint)index >= (uint)lap.Points.Length || !LapReviewTrackBounds.IsFinite(lap.Points[index].Position)) return null;
- var point = camera.Project(scene.Bounds.Normalize(lap.Points[index].Position), owner._target, owner._camera.Width, RenderSize);
+ if (!LapReviewTrackBounds.IsFinite(position)) return null;
+ var point = camera.Project(scene.Bounds.Normalize(position) + offset, owner._target, owner._camera.Width, RenderSize);
return point.X < -10 || point.Y < -10 || point.X > ActualWidth + 10 || point.Y > ActualHeight + 10 ? null : point;
}
+ Point? Project(int index)
+ {
+ return (uint)index >= (uint)lap.Points.Length ? null : ProjectPosition(lap.Points[index].Position);
+ }
if (Project(0) is { } start)
{
dc.DrawEllipse(null, new Pen(Outline, 4), start, 6, 6);
dc.DrawEllipse(null, new Pen(LapReviewPalette.StartBrush, 2), start, 6, 6);
}
- var cursorContact = owner.ShowContacts && data.Contacts?.Any(contact => contact.PointIndex == data.Cursor) == true;
- if (Project(data.Cursor) is { } cursor)
+ var cursorAtSample = (uint)data.Cursor < (uint)lap.Points.Length &&
+ (data.CursorPositionOverride is null || data.CursorPositionOverride == lap.Points[data.Cursor].Position);
+ var cursorContact = cursorAtSample && owner.ShowContacts && data.Contacts?.Any(contact => contact.PointIndex == data.Cursor) == true;
+ var cursorPosition = data.CursorPositionOverride is { } position ? ProjectPosition(position) : Project(data.Cursor);
+ if (cursorPosition is { } cursor)
dc.DrawEllipse(cursorContact ? null : Brushes.White,
new Pen(cursorContact ? Brushes.White : Outline, 1.5), cursor, cursorContact ? 10 : 4, cursorContact ? 10 : 4);
if (owner.ShowContacts && data.Contacts is { } contacts)
foreach (var contact in contacts)
if (Project(contact.PointIndex) is { } anchor)
- LapReviewPalette.DrawContact(dc, anchor, Outline, contact.PointIndex == data.Cursor);
+ LapReviewPalette.DrawContact(dc, anchor, Outline, cursorAtSample && contact.PointIndex == data.Cursor);
+ if (label is not null && fit is { } labelFit)
+ {
+ var initialFrame = LapReviewCameraFrame.From(labelFit.Yaw, LapReviewTrackFit.DefaultPitch, 0);
+ var labelPosition = labelFit.Target + initialFrame.Up * (labelFit.VerticalSpan / 2 + .025);
+ var anchor = camera.Project(labelPosition, owner._target, owner._camera.Width, RenderSize);
+ if (anchor.X < 0 || anchor.X > ActualWidth || anchor.Y < -10 || anchor.Y > ActualHeight + 10) return;
+ anchor.Y = Math.Clamp(anchor.Y, 12, Math.Max(12, ActualHeight - 12));
+ var brush = owner.TryFindResource("TextBrush") as Brush ?? Brushes.White;
+ var text = new FormattedText(label, System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+ new Typeface("Segoe UI Semibold"), 12, brush, VisualTreeHelper.GetDpi(this).PixelsPerDip);
+ dc.DrawRoundedRectangle(Outline, null, new Rect(anchor.X - 11, anchor.Y - 11, 22, 22), 5, 5);
+ dc.DrawText(text, new Point(anchor.X - text.Width / 2, anchor.Y - text.Height / 2));
+ }
}
}
}
diff --git a/src/Wisp.App/Runs/LapReviewTrackGeometry.cs b/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
index 7bea642..dc57d18 100644
--- a/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
+++ b/src/Wisp.App/Runs/LapReviewTrackGeometry.cs
@@ -14,7 +14,7 @@ internal readonly record struct LapReviewTrackBounds(Point3D Center, double Span
internal static bool IsFinite(LapPosition position) => float.IsFinite(position.X) &&
float.IsFinite(position.Y) && float.IsFinite(position.Z);
- internal static LapReviewTrackBounds From(LapReviewPlotData data)
+ internal static LapReviewTrackBounds From(LapReviewPlotData data, LapReviewLap? extraLap = null)
{
var minimum = new Point3D(double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
var maximum = new Point3D(double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity);
@@ -29,7 +29,8 @@ void Include(LapReviewPoint[] points)
}
}
if (data.Lap is { } lap) Include(lap.Points);
- if (data.Comparison?.CanCompare == true && data.Reference is { } reference) Include(reference.Points);
+ if (data.HasDistinctReference && data.Reference is { } reference) Include(reference.Points);
+ if (extraLap is not null) Include(extraLap.Points);
if (!double.IsFinite(minimum.X)) return new(new(), 1, 0);
return new(new((minimum.X + maximum.X) / 2, (minimum.Y + maximum.Y) / 2, (minimum.Z + maximum.Z) / 2),
Math.Max(1, Math.Max(maximum.X - minimum.X, Math.Max(maximum.Y - minimum.Y, maximum.Z - minimum.Z))), minimum.Y);
@@ -60,9 +61,9 @@ internal readonly record struct LapReviewTrackFit(Point3D Target, double Horizon
{
internal const double DefaultPitch = 22;
internal double Width(double aspect) => Math.Max(.03, Math.Max(HorizontalSpan, VerticalSpan * Math.Max(.01, aspect))) * 1.12;
- internal static LapReviewTrackFit From(LapReviewPlotData data, LapReviewTrackBounds bounds)
+ internal static LapReviewTrackFit From(LapReviewPlotData data, LapReviewTrackBounds bounds, double? yawOverride = null)
{
- var yaw = PrincipalYaw(data.Lap?.Points ?? [], bounds);
+ var yaw = yawOverride ?? PrincipalYaw(data.Lap?.Points ?? [], bounds);
var frame = LapReviewCameraFrame.From(yaw, DefaultPitch, 0);
var left = double.PositiveInfinity; var right = double.NegativeInfinity;
var bottom = double.PositiveInfinity; var top = double.NegativeInfinity;
@@ -84,7 +85,7 @@ void Include(LapReviewPoint[] points, bool shadow)
}
}
if (data.Lap is { } lap) Include(lap.Points, true);
- if (data.Comparison?.CanCompare == true && data.Reference is { } reference) Include(reference.Points, false);
+ if (data.HasDistinctReference && data.Reference is { } reference) Include(reference.Points, false);
if (!double.IsFinite(left)) return new(new(), 1, 1, yaw);
return new(new Point3D() + frame.Right * ((left + right) / 2) + frame.Up * ((bottom + top) / 2),
right - left, top - bottom, yaw);
@@ -116,6 +117,72 @@ private static double PrincipalYaw(LapReviewPoint[] points, LapReviewTrackBounds
internal sealed record LapReviewTrackScene(Model3DGroup Model, LapReviewTrackBounds Bounds, LapReviewTrackFit Fit, int SegmentCount);
+internal sealed record LapReviewTrackArrangement(Vector3D PrimaryOffset, Vector3D ReferenceOffset,
+ LapReviewTrackFit Overview, LapReviewTrackFit PrimaryFit, LapReviewTrackFit? ReferenceFit,
+ Point3D[] PrimaryCorners, Point3D[] ReferenceCorners)
+{
+ internal static LapReviewTrackArrangement Create(LapReviewPlotData primary, LapReviewPlotData? reference, LapReviewTrackBounds bounds)
+ {
+ var overview = LapReviewTrackFit.From(primary, bounds);
+ if (reference?.Lap is null) return new(new(), new(), overview, overview, null, [], []);
+ var first = LapReviewTrackFit.From(primary with { Reference = null, Comparison = null }, bounds, overview.Yaw);
+ var second = LapReviewTrackFit.From(reference with { Reference = null, Comparison = null }, bounds, overview.Yaw);
+ var frame = LapReviewCameraFrame.From(overview.Yaw, LapReviewTrackFit.DefaultPitch, 0);
+ double Horizontal(Point3D point) => Vector3D.DotProduct(point - new Point3D(), frame.Right);
+ double Vertical(Point3D point) => Vector3D.DotProduct(point - new Point3D(), frame.Up);
+ var center = Horizontal(overview.Target);
+ var gap = Math.Max(.06, Math.Max(first.HorizontalSpan, second.HorizontalSpan) * .14);
+ var offsetA = frame.Right * (center - gap / 2 - first.HorizontalSpan / 2 - Horizontal(first.Target));
+ var offsetB = frame.Right * (center + gap / 2 + second.HorizontalSpan / 2 - Horizontal(second.Target));
+ first = first with { Target = first.Target + offsetA };
+ second = second with { Target = second.Target + offsetB };
+ var left = Horizontal(first.Target) - first.HorizontalSpan / 2;
+ var right = Horizontal(second.Target) + second.HorizontalSpan / 2;
+ var bottom = Math.Min(Vertical(first.Target) - first.VerticalSpan / 2, Vertical(second.Target) - second.VerticalSpan / 2);
+ var top = Math.Max(Vertical(first.Target) + first.VerticalSpan / 2, Vertical(second.Target) + second.VerticalSpan / 2);
+ overview = new(new Point3D() + frame.Right * ((left + right) / 2) + frame.Up * ((bottom + top) / 2), right - left, top - bottom, overview.Yaw);
+ return new(offsetA, offsetB, overview, first, second,
+ Corners(primary.Lap!, bounds, offsetA), Corners(reference.Lap, bounds, offsetB));
+ }
+
+ internal LapReviewTrackFit ProjectedFit(double yaw, double pitch, double roll, int lap = 0)
+ {
+ if (ReferenceCorners.Length == 0) return Overview;
+ var frame = LapReviewCameraFrame.From(yaw, pitch, roll);
+ var left = double.PositiveInfinity; var right = double.NegativeInfinity;
+ var bottom = double.PositiveInfinity; var top = double.NegativeInfinity;
+ void Include(Point3D[] points)
+ {
+ foreach (var point in points)
+ {
+ var vector = point - new Point3D();
+ var x = Vector3D.DotProduct(vector, frame.Right); var y = Vector3D.DotProduct(vector, frame.Up);
+ left = Math.Min(left, x); right = Math.Max(right, x); bottom = Math.Min(bottom, y); top = Math.Max(top, y);
+ }
+ }
+ if (lap != 2) Include(PrimaryCorners);
+ if (lap != 1) Include(ReferenceCorners);
+ return new(new Point3D() + frame.Right * ((left + right) / 2) + frame.Up * ((bottom + top) / 2), right - left, top - bottom, yaw);
+ }
+
+ private static Point3D[] Corners(LapReviewLap lap, LapReviewTrackBounds bounds, Vector3D offset)
+ {
+ var low = new Point3D(double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity);
+ var high = new Point3D(double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity);
+ foreach (var sample in lap.Points)
+ {
+ if (!LapReviewTrackBounds.IsFinite(sample.Position)) continue;
+ var point = bounds.Normalize(sample.Position) + offset;
+ low = new(Math.Min(low.X, point.X), Math.Min(low.Y, point.Y), Math.Min(low.Z, point.Z));
+ high = new(Math.Max(high.X, point.X), Math.Max(high.Y, point.Y), Math.Max(high.Z, point.Z));
+ }
+ if (!double.IsFinite(low.X)) return [new Point3D() + offset];
+ low.Y = Math.Min(low.Y, (bounds.MinimumHeight - bounds.Center.Y) / bounds.Span - .025 + offset.Y);
+ return [new(low.X, low.Y, low.Z), new(low.X, low.Y, high.Z), new(low.X, high.Y, low.Z), new(low.X, high.Y, high.Z),
+ new(high.X, low.Y, low.Z), new(high.X, low.Y, high.Z), new(high.X, high.Y, low.Z), new(high.X, high.Y, high.Z)];
+ }
+}
+
internal static class LapReviewTrackGeometry
{
// At most six recorded samples per bucket, including height and channel extrema. The
@@ -166,6 +233,11 @@ internal static int[] RetainedIndices(LapReviewPoint[] points, Func 0 } lap) { model.Freeze(); return new(model, bounds, fit, 0); }
@@ -201,7 +273,7 @@ internal static LapReviewTrackScene Build(LapReviewPlotData data, CancellationTo
}
Add(model, shadow, Brush(26, 32, 42));
Add(model, posts, Brush(40, 48, 59));
- if (data.Comparison?.CanCompare == true && data.Reference is { } reference)
+ if (data.ShowReferencePath && data.HasDistinctReference && data.Reference is { } reference)
{
var referenceCount = 0;
var referenceSegments = Segments(reference.Points, RetainedIndices(reference.Points, cancellationToken: cancellationToken)).ToArray();
diff --git a/src/Wisp.App/Runs/LapReviewView.Scrubbing.cs b/src/Wisp.App/Runs/LapReviewView.Scrubbing.cs
new file mode 100644
index 0000000..fb51e07
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewView.Scrubbing.cs
@@ -0,0 +1,49 @@
+using System.Windows;
+using System.Windows.Controls.Primitives;
+using System.Windows.Media;
+
+namespace Wisp.App.Runs;
+
+public partial class LapReviewView
+{
+ private bool _scrubbing, _scrubPending, _flushingScrub;
+ internal int ScrubUpdates { get; private set; }
+
+ private void InitializeScrubbing()
+ {
+ LapCursorSlider.AddHandler(Thumb.DragStartedEvent, new DragStartedEventHandler((_, _) => BeginScrub()));
+ LapCursorSlider.AddHandler(Thumb.DragCompletedEvent, new DragCompletedEventHandler((_, _) => EndScrub()));
+ Unloaded += (_, _) => EndScrub();
+ DataContextChanged += (_, _) => CancelScrub();
+ }
+
+ private void LapCursorSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
+ {
+ if (_flushingScrub) return;
+ if (!_scrubbing) { FlushScrub(); return; }
+ if (_scrubPending) return;
+ _scrubPending = true;
+ CompositionTarget.Rendering += RenderScrub;
+ }
+ private void RenderScrub(object? sender, EventArgs e) => FlushScrub();
+ internal void BeginScrub() => _scrubbing = true;
+ internal void EndScrub() { if (_scrubPending) FlushScrub(); _scrubbing = false; }
+ private void CancelScrub()
+ {
+ CompositionTarget.Rendering -= RenderScrub;
+ _scrubPending = _scrubbing = false;
+ }
+ internal void FlushScrub()
+ {
+ CompositionTarget.Rendering -= RenderScrub;
+ _scrubPending = false;
+ if (_flushingScrub || LapCursorSlider is null) return;
+ _flushingScrub = true;
+ try
+ {
+ LapCursorSlider.GetBindingExpression(RangeBase.ValueProperty)?.UpdateSource();
+ ScrubUpdates++;
+ }
+ finally { _flushingScrub = false; }
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewView.Workspace.cs b/src/Wisp.App/Runs/LapReviewView.Workspace.cs
new file mode 100644
index 0000000..a8b473f
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewView.Workspace.cs
@@ -0,0 +1,24 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace Wisp.App.Runs;
+
+public partial class LapReviewView
+{
+ private void InitializeMapWorkspace()
+ {
+ CameraControls.DataContext = Track3D;
+ Track3D.ReferencePointChosen += index =>
+ {
+ if (DataContext is LapReviewViewModel model) model.PickReferencePoint(index);
+ };
+ Track3D.PreviewKeyDown += (_, key) =>
+ {
+ if (key.Key != Key.Escape || !Track3D.IsComparison) return;
+ Track3D.ShowAll();
+ key.Handled = true;
+ };
+ }
+
+ private void ShowBothMaps_Click(object sender, RoutedEventArgs e) => Track3D.ShowAll();
+}
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index 62bc8b1..89ea057 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -51,63 +51,21 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -156,18 +128,22 @@
+
-
-
-
-
+
+
+
+
+
+
-
+
+
@@ -180,8 +156,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index 701ec95..209b3e7 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -16,6 +16,8 @@ public LapReviewView()
Track.PointChosen += Pick;
Track3D.PointChosen += Pick;
Trace.PointChosen += Pick;
+ InitializeMapWorkspace();
+ InitializeScrubbing();
}
private void Pick(int index) { if (DataContext is LapReviewViewModel model) model.Cursor = index; }
private void ResetMapView_Click(object sender, RoutedEventArgs e) => Track3D.ResetView();
@@ -78,7 +80,12 @@ internal BitmapSource CaptureMapImage()
{
drawing.DrawRectangle(MapExportSurface.Background, null,
new Rect(0, 0, bitmap.PixelWidth / scale, bitmap.PixelHeight / scale));
- var brush = new VisualBrush(MapExportSurface) { Stretch = Stretch.Fill };
+ var brush = new VisualBrush(MapExportSurface)
+ {
+ Stretch = Stretch.Fill,
+ ViewboxUnits = BrushMappingMode.Absolute,
+ Viewbox = new Rect((Point)VisualTreeHelper.GetOffset(MapExportSurface), size)
+ };
drawing.DrawRectangle(brush, null, new Rect(size));
}
bitmap.Render(visual); bitmap.Freeze();
@@ -89,6 +96,6 @@ internal BitmapSource CaptureMapImage()
public sealed class LapReviewMapHeightConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
- value is double height && double.IsFinite(height) && height > 0 ? Math.Clamp(height - 8, 180, 360) : 360d;
+ value is double height && double.IsFinite(height) && height > 0 ? Math.Clamp(height - 180, 128, 360) : 360d;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
index aff1b97..6f7f66a 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -15,7 +15,7 @@ public sealed partial class LapReviewViewModel
private LapReviewPlotData? _legendData;
private LapContactChoice? _selectedContact;
private string _mapStatus = "";
- public bool Is3D { get => _is3D; set { if (Set(ref _is3D, value)) Changed(nameof(Is2D)); } }
+ public bool Is3D { get => _is3D; set { if (Set(ref _is3D, value)) { Changed(nameof(Is2D)); Changed(nameof(MapPlotA)); Changed(nameof(ComparisonMapPlot)); Changed(nameof(IsMapComparison)); Changed(nameof(HasMapReference)); } } }
public bool Is2D { get => !Is3D; set { if (value) Is3D = false; } }
public bool ShowContacts { get => _showContacts; set { if (Set(ref _showContacts, value)) Changed(nameof(Plot)); } }
public string MapStatus { get => _mapStatus; set => Set(ref _mapStatus, value); }
@@ -26,7 +26,7 @@ public sealed partial class LapReviewViewModel
public string MapCursorValue => Lap is { Points.Length: > 0 } lap ?
$"{Channel.Label}: {RunPresentation.Number(LapReviewPlot.Value(lap.Points[Cursor], Plot, Cursor), " " + LapReviewPlot.Unit(Plot))}" : "";
public string ElevationSummary { get; private set; } = "";
- public bool HasMapReference => _comparison?.CanCompare == true && Reference is not null;
+ public bool HasMapReference => !Is3D && Plot.HasDistinctReference;
public string ContactSummary => _contacts.Length == 0 ? "No contact markers" : $"{_contacts.Length} contact marker{(_contacts.Length == 1 ? "" : "s")}";
public IReadOnlyList ContactChoices { get; private set; } = [];
public LapContactChoice? SelectedContact
@@ -128,15 +128,21 @@ private void RefreshMapDetails()
old.TemperatureUnit != data.TemperatureUnit || old.TorqueUnit != data.TorqueUnit)
{
_legendData = data;
- var range = LapReviewColorRange.From(data);
+ var range = SharedMapRange(data);
+ _sharedMapRange = range;
LegendTitle = Channel.Label + (IsWheelChannel ? " · " + Wheels[SelectedWheel] : "");
LegendMinimum = range.HasValues ? $"{range.Minimum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
LegendMaximum = range.HasValues ? $"{range.Maximum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
- ElevationSummary = data.Lap is { Points.Length: > 0 } lap ?
- $"Recorded elevation: {lap.Points.Min(p => p.Position.Y):0.0}–{lap.Points.Max(p => p.Position.Y):0.0} m · true scale" : "";
- foreach (var name in new[] { nameof(LegendMinimum), nameof(LegendMaximum), nameof(LegendTitle), nameof(ElevationSummary), nameof(MapTitle) }) Changed(name);
+ var positions = HasMapComparison && Reference is { } reference
+ ? data.Lap!.Points.Concat(reference.Points) : data.Lap?.Points ?? [];
+ ElevationSummary = data.Lap is { Points.Length: > 0 } ?
+ $"{(HasMapComparison ? "Both laps’ recorded elevation" : "Recorded elevation")}: {positions.Min(p => p.Position.Y):0.0}–{positions.Max(p => p.Position.Y):0.0} m · true scale" : "";
+ foreach (var name in new[] { nameof(LegendMinimum), nameof(LegendMaximum), nameof(LegendTitle), nameof(ElevationSummary), nameof(MapTitle), nameof(MapATitle), nameof(MapBTitle) }) Changed(name);
}
- Changed(nameof(MapCursorValue)); Changed(nameof(HasMapReference)); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
+ Changed(nameof(MapCursorValue)); Changed(nameof(MapBCursorValue)); Changed(nameof(MapPlotA)); Changed(nameof(MapPlotB));
+ Changed(nameof(ComparisonMapPlot));
+ Changed(nameof(IsMapComparison));
+ Changed(nameof(HasMapComparison)); Changed(nameof(MapComparisonHint)); Changed(nameof(HasMapReference)); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
if (MarkContactCommand is RunUiCommand mark) mark.RaiseCanExecuteChanged();
if (RemoveContactCommand is RunUiCommand remove) remove.RaiseCanExecuteChanged();
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
new file mode 100644
index 0000000..8ae95cf
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
@@ -0,0 +1,71 @@
+using Wisp.Core.Runs;
+
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewViewModel
+{
+ private LapReviewColorRange? _sharedMapRange;
+ private LapReviewContact[] _referenceMapContacts = [];
+ public bool HasMapComparison => Plot.HasDistinctReference && _reverseComparison?.CanCompare == true;
+ public bool IsMapComparison => Is3D && HasMapComparison;
+ public string MapATitle => Lap is { } lap ? $"A · {lap.RunName} · {lap.Label}" : "Run A";
+ public string MapBTitle => Reference is { } lap ? $"B · {lap.RunName} · {lap.Label}" : "Run B";
+ public string MapComparisonHint => HasMapComparison
+ ? "Both laps use the same color scale. Click a track to focus it; zoom out to see both."
+ : "Use Compare above to choose Run B, or choose another reference lap in Lap settings.";
+ public string MapBCursorValue
+ {
+ get
+ {
+ if (!HasMapComparison) return "";
+ var data = ReferencePlot();
+ var value = LapReviewPlot.ReferencePosition(Plot, Cursor) is null ? null :
+ Channel.Channel == LapReviewChannel.Delta
+ ? -_comparison?.Points.ElementAtOrDefault(Cursor)?.DeltaSeconds : LapReviewPlot.ReferenceValue(Plot, Cursor);
+ return $"{Channel.Label}: {RunPresentation.Number(value, " " + LapReviewPlot.Unit(data))}";
+ }
+ }
+ public LapReviewPlotData MapPlotA => Plot with { ShowReferencePath = !Is3D, ColorRangeOverride = _sharedMapRange };
+ public LapReviewPlotData? ComparisonMapPlot => IsMapComparison ? MapPlotB : null;
+ public LapReviewPlotData MapPlotB
+ {
+ get
+ {
+ var data = ReferencePlot();
+ var position = LapReviewPlot.ReferencePosition(Plot, Cursor);
+ return data with
+ {
+ ShowReferencePath = false,
+ ColorRangeOverride = _sharedMapRange,
+ Cursor = position.HasValue ? data.Cursor : -1,
+ CursorPositionOverride = position
+ };
+ }
+ }
+
+ private LapReviewPlotData ReferencePlot()
+ {
+ int Match(int index) => _comparison?.Points.ElementAtOrDefault(index)?.ReferencePointIndex ?? -1;
+ var start = Match(_sectionStart); var end = Match(_sectionEnd);
+ var matchedSection = start >= 0 && end >= start;
+ return new(Reference, Lap, _reverseComparison, Channel.Channel, _settings.SpeedUnit, Match(Cursor),
+ matchedSection ? start : -1, matchedSection ? end : -1,
+ SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, ShowContacts ? _referenceMapContacts : null);
+ }
+
+ public void PickReferencePoint(int index)
+ {
+ if (!HasMapComparison || _reverseComparison!.Points.ElementAtOrDefault(index)?.ReferencePointIndex is not { } matched) return;
+ Cursor = matched;
+ }
+
+ private LapReviewColorRange SharedMapRange(LapReviewPlotData data)
+ {
+ var a = LapReviewColorRange.From(data);
+ if (!HasMapComparison) return a;
+ var b = LapReviewColorRange.From(ReferencePlot());
+ if (!a.HasValues) return b;
+ if (!b.HasValues) return a;
+ return new(Math.Min(a.Minimum, b.Minimum), Math.Max(a.Maximum, b.Maximum), true);
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.cs b/src/Wisp.App/Runs/LapReviewViewModel.cs
index 967fb86..467b7cf 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.cs
@@ -22,11 +22,11 @@ public sealed partial class LapReviewViewModel : INotifyPropertyChanged, IDispos
private readonly AppSettings _settings;
private readonly RunStore _store;
private readonly Action _save;
- private RecordedRun? _run, _comparisonRun;
+ private RecordedRun? _run, _comparisonRun, _referenceRun;
private CancellationTokenSource? _loadCancellation, _analysisCancellation;
private readonly List _commands = [];
private LapReviewLap? _lap, _reference;
- private LapReviewComparison? _comparison;
+ private LapReviewComparison? _comparison, _reverseComparison;
private LapTimingChoice _timing;
private LapChannelChoice _channel;
private int _cursor, _sectionStart, _sectionEnd;
@@ -155,6 +155,7 @@ private async Task LoadAsync()
return (a, b, current, usePin, pinMatch);
}, cancel.Token);
if (_disposed || cancel.IsCancellationRequested) return;
+ _referenceRun = result.usePin ? pinned : comparisonRun ?? run;
_applying = true;
try
{
@@ -190,7 +191,7 @@ private async Task LoadAsync()
}
private void RefreshComparison()
{
- _comparison = null;
+ _comparison = _reverseComparison = null;
RefreshCursor(); Changed(nameof(Plot));
_ = AnalyzeAsync(compare: true);
}
@@ -214,7 +215,8 @@ private void ClearReview()
_applying = true;
try
{
- _lap = _reference = null; _comparison = null;
+ _lap = _reference = null; _comparison = _reverseComparison = null;
+ _referenceRun = null; _referenceMapContacts = [];
ClearTelemetryContacts();
_cursor = _sectionStart = _sectionEnd = 0;
Laps.Clear(); ReferenceLaps.Clear();
@@ -234,7 +236,7 @@ private async Task AnalyzeAsync(bool compare)
if (_disposed || _applying) return;
_analysisCancellation?.Cancel();
var cancel = new CancellationTokenSource(); _analysisCancellation = cancel;
- var lap = Lap; var reference = Reference; var comparison = _comparison;
+ var lap = Lap; var reference = Reference; var comparison = _comparison; var reverse = _reverseComparison;
_sectionStatistics = _referenceStatistics = null; _referenceSectionSeconds = null;
Metrics.Clear(); Events.Clear();
SectionText = lap is { Points.Length: > 1 } ? "Preparing the selected section…" : "";
@@ -245,10 +247,21 @@ private async Task AnalyzeAsync(bool compare)
_sectionStart = Math.Clamp(_sectionStart, 0, MaximumCursor); _sectionEnd = Math.Clamp(_sectionEnd, _sectionStart, MaximumCursor);
var first = _sectionStart; var last = _sectionEnd;
var cachedContacts = PreparedTelemetryContacts(lap);
+ var referenceMarkers = _referenceRun?.Markers ?? [];
+ var cachedReferenceContacts = _referenceMapContacts;
var result = await Task.Run(() =>
{
var contacts = cachedContacts ?? LapReviewContacts.Find(lap, cancel.Token);
- if (compare) comparison = reference is null ? null : LapReviewAnalysis.Compare(lap, reference, cancel.Token);
+ var referenceContacts = compare && reference is not null
+ ? LapReviewContacts.Find(reference, cancel.Token).Concat(LapReviewContacts.FromMarkers(reference, referenceMarkers)).OrderBy(contact => contact.PointIndex).ToArray()
+ : cachedReferenceContacts;
+ if (compare)
+ {
+ comparison = reference is null ? null : LapReviewAnalysis.Compare(lap, reference, cancel.Token);
+ reverse = comparison?.CanCompare == true && reference is not null &&
+ new LapReviewPlotData(lap, reference, comparison, LapReviewChannel.Speed, default, 0, 0, 0).HasDistinctReference
+ ? LapReviewAnalysis.Compare(reference, lap, cancel.Token) : null;
+ }
var stats = LapReviewAnalysis.AnalyzeSection(lap, first, last, cancel.Token);
LapSectionStatistics? other = null;
double? referenceSeconds = null;
@@ -261,13 +274,14 @@ private async Task AnalyzeAsync(bool compare)
if (from.ReferenceLapSeconds is { } start && to.ReferenceLapSeconds is { } end && end >= start)
referenceSeconds = end - start;
}
- return (comparison, stats, other, referenceSeconds, contacts);
+ return (comparison, reverse, stats, other, referenceSeconds, contacts, referenceContacts);
}, cancel.Token);
if (_disposed || cancel.IsCancellationRequested || !ReferenceEquals(_analysisCancellation, cancel) ||
!ReferenceEquals(lap, Lap) || !ReferenceEquals(reference, Reference)) return;
- _comparison = result.comparison; _sectionStatistics = result.stats;
+ _comparison = result.comparison; _reverseComparison = result.reverse; _sectionStatistics = result.stats;
_referenceStatistics = result.other; _referenceSectionSeconds = result.referenceSeconds;
AdoptTelemetryContacts(lap, result.contacts);
+ _referenceMapContacts = result.referenceContacts;
FormatSection(); RefreshCursor(); Changed(nameof(Plot));
}
catch (OperationCanceledException) { }
@@ -275,7 +289,7 @@ private async Task AnalyzeAsync(bool compare)
{
if (!_disposed && !cancel.IsCancellationRequested)
{
- _comparison = null;
+ _comparison = _reverseComparison = null;
SectionText = "This section could not be prepared. Select the lap again to retry.";
Changed(nameof(SectionText)); RefreshCursor(); Changed(nameof(Plot));
}
diff --git a/src/Wisp.App/Runs/RunsPage.xaml.cs b/src/Wisp.App/Runs/RunsPage.xaml.cs
index df411b3..a75556a 100644
--- a/src/Wisp.App/Runs/RunsPage.xaml.cs
+++ b/src/Wisp.App/Runs/RunsPage.xaml.cs
@@ -16,6 +16,7 @@ public partial class RunsPage : RunsPageBase
public bool IsWorkspaceShort => (bool)GetValue(IsWorkspaceShortProperty);
protected override bool UsesModularWorkspace => true;
+ protected override FrameworkElement ReportFocusTarget => LapReviewExpander.IsExpanded ? LapReviewExpander : base.ReportFocusTarget;
public RunsPage()
{
diff --git a/src/Wisp.App/Runs/RunsPageBase.cs b/src/Wisp.App/Runs/RunsPageBase.cs
index 607ba31..93b8a17 100644
--- a/src/Wisp.App/Runs/RunsPageBase.cs
+++ b/src/Wisp.App/Runs/RunsPageBase.cs
@@ -19,6 +19,7 @@ public class RunsPageBase : UserControl
private Button ActiveShortcutButton => _capturingMarkerShortcut ? _elements.MarkerShortcutCaptureButton : _elements.ShortcutCaptureButton;
protected RunsViewModel? Model => DataContext as RunsViewModel;
protected virtual bool UsesModularWorkspace => false;
+ protected virtual FrameworkElement ReportFocusTarget => _elements.RunHeading;
protected sealed record PageElements(
Button ShortcutCaptureButton,
@@ -82,7 +83,7 @@ private void OpenReport(object? sender, EventArgs e)
{
Model?.ShowSummary();
if (!IsVisible) return;
- _ = Dispatcher.InvokeAsync(() => { if (IsVisible) ScrollTo(_elements.RunHeading, _elements.RunsScroll); }, System.Windows.Threading.DispatcherPriority.Loaded);
+ _ = Dispatcher.InvokeAsync(() => { if (IsVisible) ScrollTo(ReportFocusTarget, _elements.RunsScroll); }, System.Windows.Threading.DispatcherPriority.Loaded);
}
private void ScrollTo(FrameworkElement target, ScrollViewer scroll)
{
diff --git a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
new file mode 100644
index 0000000..b139bab
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
@@ -0,0 +1,243 @@
+using System.IO;
+using System.Runtime.ExceptionServices;
+using System.Windows.Threading;
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+using Wisp.UiReview;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class LapReviewMapComparisonTests
+{
+ [Fact]
+ public void DistinctComparableLapsUseOneScaleForBothMapsAndTheLegend() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var reference = LapReviewFixtures.Create(reference: true);
+ reference = reference with
+ {
+ Samples = reference.Samples.Select(s => s with { State = s.State with { PowerWatts = s.State.PowerWatts * 2 } }).ToArray()
+ };
+ model.SetRuns(LapReviewFixtures.Create(), reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true;
+ Assert.True(model.HasMapComparison);
+ Assert.False(model.MapPlotA.ShowReferencePath);
+ Assert.False(model.MapPlotB.ShowReferencePath);
+ foreach (var channel in new[] { LapReviewChannel.Speed, LapReviewChannel.Power, LapReviewChannel.Brake })
+ {
+ model.Channel = model.Channels.Single(c => c.Channel == channel);
+ var a = model.MapPlotA; var b = model.MapPlotB;
+ var first = LapReviewColorRange.From(a with { ColorRangeOverride = null });
+ var second = LapReviewColorRange.From(b with { ColorRangeOverride = null });
+ var expected = new LapReviewColorRange(Math.Min(first.Minimum, second.Minimum), Math.Max(first.Maximum, second.Maximum), true);
+ Assert.Equal(expected, a.ColorRangeOverride);
+ Assert.Equal(expected, b.ColorRangeOverride);
+ Assert.Equal($"{expected.Minimum:0.##} {LapReviewPlot.Unit(a)}", model.LegendMinimum);
+ Assert.Equal($"{expected.Maximum:0.##} {LapReviewPlot.Unit(a)}", model.LegendMaximum);
+ }
+ var scale = model.MapPlotA.ColorRangeOverride;
+ var metrics = model.Metrics[0];
+ model.Cursor = 150;
+ Assert.Equal(scale, model.MapPlotA.ColorRangeOverride);
+ Assert.Same(metrics, model.Metrics[0]);
+ Assert.False(model.IsBusy);
+ });
+
+ [Fact]
+ public void ReferenceCursorAndSectionUseSpatialMatchesAndPickingBMovesTheSharedCursor() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Cursor = 150;
+ Assert.Equal(model.Plot.Comparison!.Points[150].ReferencePointIndex, model.MapPlotB.Cursor);
+ Assert.InRange(model.MapPlotB.Cursor, 0, model.Reference!.Points.Length - 1);
+ var reverse = model.MapPlotB.Comparison!.Points[220].ReferencePointIndex;
+ Assert.NotNull(reverse);
+ model.PickReferencePoint(220);
+ Assert.Equal(reverse.Value, model.Cursor);
+ model.Cursor = 75;
+ model.SectionStartCommand.Execute(null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Cursor = 180;
+ model.SectionEndCommand.Execute(null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.Equal(model.Plot.Comparison!.Points[75].ReferencePointIndex, model.MapPlotB.SectionStart);
+ Assert.Equal(model.Plot.Comparison.Points[180].ReferencePointIndex, model.MapPlotB.SectionEnd);
+ var selected = model.Cursor;
+ model.PickReferencePoint(-1); model.PickReferencePoint(int.MaxValue);
+ Assert.Equal(selected, model.Cursor);
+ });
+
+ [Fact]
+ public void ReferenceCursorInterpolatesBetweenOriginalSamplesInsteadOfSnappingToTheEarlierOne() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var reference = LapReviewFixtures.Create(reference: true);
+ reference = reference with
+ {
+ Samples = reference.Samples.Select((sample, index) =>
+ {
+ var angle = (index % 600 + .5) / 600 * Math.Tau;
+ var lap = sample.State.Lap!;
+ return sample with
+ {
+ State = sample.State with
+ {
+ Lap = lap with
+ {
+ Position = new((float)(210 * Math.Cos(angle)),
+ (float)(80 + 24 * Math.Sin(angle) + 7 * Math.Sin(angle * 3)), (float)(140 * Math.Sin(angle)))
+ }
+ }
+ };
+ }).ToArray()
+ };
+ model.SetRuns(LapReviewFixtures.Create(), reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.True(model.HasMapComparison);
+ model.Cursor = 150;
+ var match = model.Plot.Comparison!.Points[model.Cursor];
+ var lower = Assert.IsType(match.ReferencePointIndex);
+ var seconds = Assert.IsType(match.ReferenceLapSeconds);
+ var a = model.Reference!.Points[lower]; var b = model.Reference.Points[lower + 1];
+ var fraction = (seconds - a.LapSeconds) / (b.LapSeconds - a.LapSeconds);
+ Assert.InRange(fraction, .1, .9);
+ var cursor = Assert.IsType(model.MapPlotB.CursorPositionOverride);
+ Assert.Equal(a.Position.X + (b.Position.X - a.Position.X) * fraction, cursor.X, 4);
+ Assert.Equal(a.Position.Y + (b.Position.Y - a.Position.Y) * fraction, cursor.Y, 4);
+ Assert.Equal(a.Position.Z + (b.Position.Z - a.Position.Z) * fraction, cursor.Z, 4);
+ Assert.NotEqual(a.Position, cursor);
+ });
+
+ [Fact]
+ public void UnmatchedSectionEndpointHidesReferenceSelectionAndUnmatchedCursor() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Cursor = 75;
+ model.SectionStartCommand.Execute(null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ // An accepted route may still contain individual unmatched positions.
+ var comparison = model.Plot.Comparison!;
+ comparison.Points[75] = comparison.Points[75] with
+ { ReferencePointIndex = null, ReferenceLapSeconds = null, DeltaSeconds = null };
+ model.Cursor = 180;
+ model.SectionEndCommand.Execute(null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.True(model.HasMapComparison);
+ Assert.Equal(-1, model.MapPlotB.SectionStart);
+ Assert.Equal(-1, model.MapPlotB.SectionEnd);
+ model.Cursor = 75;
+ Assert.Equal(-1, model.MapPlotB.Cursor);
+ Assert.Null(model.MapPlotB.CursorPositionOverride);
+ Assert.Contains("Unavailable", model.MapBCursorValue);
+ });
+
+ [Fact]
+ public void EachMapKeepsItsOwnContactsAndTheVisibilityToggleAppliesToBoth() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create() with { Markers = [new(10, "Contact")] },
+ LapReviewFixtures.Create(reference: true) with { Markers = [new(15, "Contact")] });
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.True(model.HasMapComparison);
+ var first = Assert.Single(model.MapPlotA.Contacts!, c => c.Kind == LapReviewContactKind.UserMarkedContact);
+ var second = Assert.Single(model.MapPlotB.Contacts!, c => c.Kind == LapReviewContactKind.UserMarkedContact);
+ Assert.Equal(10, first.RunSeconds); Assert.Equal(15, second.RunSeconds);
+ Assert.InRange(first.PointIndex, 0, model.Lap!.Points.Length - 1);
+ Assert.InRange(second.PointIndex, 0, model.Reference!.Points.Length - 1);
+ model.ShowContacts = false;
+ Assert.Empty(model.MapPlotA.Contacts ?? []); Assert.Empty(model.MapPlotB.Contacts ?? []);
+ model.ShowContacts = true;
+ Assert.Contains(first, model.MapPlotA.Contacts!); Assert.Contains(second, model.MapPlotB.Contacts!);
+ });
+
+ [Fact]
+ public void SelfReferenceMissingReferenceAndClearDoNotCreateASecondMap() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create(), null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.True(model.HasLap); Assert.NotNull(model.Reference);
+ Assert.False(model.HasMapComparison);
+ model.Reference = null;
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.False(model.HasMapComparison); Assert.Null(model.MapPlotB.Lap);
+ Assert.Equal("", model.MapBCursorValue);
+ model.SetRuns(null, null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.False(model.HasLap); Assert.False(model.HasMapComparison);
+ Assert.Null(model.MapPlotA.Lap); Assert.Null(model.MapPlotB.Lap);
+ });
+
+ [Fact]
+ public void IncompatibleRoutesDoNotOfferMatchedReferenceNavigation() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var reference = LapReviewFixtures.Create(reference: true);
+ reference = reference with
+ {
+ Samples = reference.Samples.Select(s =>
+ {
+ var lap = s.State.Lap!;
+ return s with { State = s.State with { Lap = lap with { Position = lap.Position with { X = lap.Position.X + 2000 } } } };
+ }).ToArray()
+ };
+ fixture.Model.SetRuns(LapReviewFixtures.Create(), reference);
+ await LapReviewComparisonTestSupport.Ready(fixture.Model);
+ Assert.False(fixture.Model.HasMapComparison);
+ fixture.Model.Cursor = 120;
+ fixture.Model.PickReferencePoint(220);
+ Assert.Equal(120, fixture.Model.Cursor);
+ });
+}
+
+internal sealed class LapReviewComparisonTestSupport : IDisposable
+{
+ private readonly string _directory = Path.Combine(Path.GetTempPath(), "Wisp.LapReviewComparisonTests", Guid.NewGuid().ToString("N"));
+ internal LapReviewViewModel Model { get; }
+ internal LapReviewComparisonTestSupport() => Model = new(new AppSettings(), new RunStore(_directory), () => { });
+ public void Dispose()
+ {
+ Model.Dispose();
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+ internal static async Task Ready(LapReviewViewModel model)
+ {
+ for (var i = 0; i < 500 && model.IsBusy; i++) await Task.Delay(10, TestContext.Current.CancellationToken);
+ Assert.False(model.IsBusy);
+ Assert.DoesNotContain("could not be prepared", model.Status);
+ Assert.DoesNotContain("could not be prepared", model.SectionText);
+ }
+ internal static void OnDispatcher(Func test)
+ {
+ Exception? error = null;
+ using var finished = new ManualResetEventSlim();
+ var thread = new Thread(() =>
+ {
+ var dispatcher = Dispatcher.CurrentDispatcher;
+ _ = dispatcher.InvokeAsync(async () =>
+ {
+ try { await test(); }
+ catch (Exception failure) { error = failure; }
+ finally { finished.Set(); dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); }
+ });
+ Dispatcher.Run();
+ })
+ { IsBackground = true };
+ thread.SetApartmentState(ApartmentState.STA); thread.Start();
+ Assert.True(finished.Wait(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken), "Lap map comparison exceeded the dispatcher deadline.");
+ if (error is not null) ExceptionDispatchInfo.Capture(error).Throw();
+ }
+}
diff --git a/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs b/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs
new file mode 100644
index 0000000..ee86143
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs
@@ -0,0 +1,131 @@
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using Wisp.App.Runs;
+using Wisp.Core;
+using Wisp.Core.Runs;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class LapReviewPlotRenderingTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void CursorMovementReusesStaticDrawingAndChangesOnlyTheMarker(bool isMap) => OnSta(() =>
+ {
+ var plot = Create(isMap);
+ Render(plot);
+ var prepared = Prepared(plot);
+ for (var i = 0; i < 12; i++)
+ {
+ plot.Data = plot.Data! with { Cursor = i % 3 };
+ Render(plot);
+ Assert.Same(prepared, Prepared(plot));
+ }
+ });
+
+ [Fact]
+ public void SharedColorScaleChangesActualPixelsAndInvalidatesTheCachedDrawing() => OnSta(() =>
+ {
+ var plot = Create(true);
+ var bitmap = Render(plot);
+ var prepared = Prepared(plot);
+ AssertPixel(bitmap, 200, 172, LapReviewPalette.GetBrush(.5));
+ plot.Data = plot.Data! with { ColorRangeOverride = new(0, 100, true) };
+ bitmap = Render(plot);
+ Assert.NotSame(prepared, Prepared(plot));
+ AssertPixel(bitmap, 200, 172, LapReviewPalette.GetBrush(.1));
+ prepared = Prepared(plot);
+ plot.Data = plot.Data with { ColorRangeOverride = new(0, 100, true) };
+ Render(plot);
+ Assert.Same(prepared, Prepared(plot));
+ });
+
+ [Fact]
+ public void HidingReferencePathKeepsSharedMapBoundsAndDoesNotHideCurrentLap() => OnSta(() =>
+ {
+ var plot = Create(true);
+ var bitmap = Render(plot);
+ var prepared = Prepared(plot);
+ var referencePixel = new byte[4];
+ bitmap.CopyPixels(new Int32Rect(200, 28, 1, 1), referencePixel, 4, 0);
+ Assert.Contains(referencePixel.Take(3), value => value > 0);
+ AssertPixel(bitmap, 200, 172, LapReviewPalette.GetBrush(.5));
+ plot.Data = plot.Data! with { ShowReferencePath = false };
+ bitmap = Render(plot);
+ Assert.NotSame(prepared, Prepared(plot));
+ AssertPixel(bitmap, 200, 28, Brushes.Black);
+ AssertPixel(bitmap, 200, 172, LapReviewPalette.GetBrush(.5));
+ });
+
+ private static LapReviewPlot Create(bool isMap)
+ {
+ var state = RunTestData.State() with { GroundSpeedMetersPerSecond = 10 / 3.6f };
+ var lap = new LapReviewLap
+ {
+ RunId = Guid.Parse("229b456f-7216-4f83-8ad4-dbb119227db6"),
+ Points = Enumerable.Range(0, 3).Select(i => new LapReviewPoint(i, i, i, i * 50,
+ new(i * 50, 0, 0), i == 0, new() { State = state })).ToArray()
+ };
+ var reference = lap with
+ {
+ RunId = Guid.Parse("5f6fcd2e-b086-4be9-b171-9c26e2317b73"),
+ Points = lap.Points.Select(p => p with { Position = p.Position with { Z = 100 } }).ToArray()
+ };
+ var comparison = new LapReviewComparison(lap.Points.Select((p, i) =>
+ new LapReviewComparisonPoint(i, p.DistanceMeters, p.LapSeconds, 0, i)).ToArray(), 1, true, "matched");
+ var plot = new LapReviewPlot
+ {
+ IsMap = isMap,
+ Data = new(lap, reference, comparison, LapReviewChannel.Speed, SpeedUnit.KilometersPerHour, 0, 0, 2)
+ };
+ plot.Resources["InputBrush"] = Brushes.Black;
+ plot.Resources["TextBrush"] = Brushes.White;
+ plot.Resources["MutedBrush"] = Brushes.Gray;
+ plot.Measure(new Size(400, 200)); plot.Arrange(new Rect(0, 0, 400, 200));
+ return plot;
+ }
+
+ private static DrawingGroup Prepared(LapReviewPlot plot) => Assert.IsType(
+ typeof(LapReviewPlot).GetField("_preparedDrawing", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(plot));
+
+ private static RenderTargetBitmap Render(LapReviewPlot plot)
+ {
+ plot.UpdateLayout();
+ var bitmap = new RenderTargetBitmap(400, 200, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(plot);
+ Assert.Null(PresentationSource.FromVisual(plot));
+ return bitmap;
+ }
+
+ private static void AssertPixel(BitmapSource bitmap, int x, int y, Brush brush)
+ {
+ var expected = Assert.IsType(brush).Color;
+ var pixel = new byte[4];
+ bitmap.CopyPixels(new Int32Rect(x, y, 1, 1), pixel, 4, 0);
+ Assert.InRange(Math.Abs(pixel[0] - expected.B), 0, 2);
+ Assert.InRange(Math.Abs(pixel[1] - expected.G), 0, 2);
+ Assert.InRange(Math.Abs(pixel[2] - expected.R), 0, 2);
+ Assert.Equal(255, pixel[3]);
+ }
+
+ private static void OnSta(Action action)
+ {
+ Exception? error = null;
+ var thread = new Thread(() =>
+ {
+ try { action(); }
+ catch (Exception exception) { error = exception; }
+ finally { Dispatcher.CurrentDispatcher.InvokeShutdown(); }
+ })
+ { IsBackground = true };
+ thread.SetApartmentState(ApartmentState.STA); thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(15)), "Lap review plot rendering timed out.");
+ if (error is not null) ExceptionDispatchInfo.Capture(error).Throw();
+ }
+}
diff --git a/tests/Wisp.App.Tests/LapReviewPlotValueTests.cs b/tests/Wisp.App.Tests/LapReviewPlotValueTests.cs
index d09cf43..686cd70 100644
--- a/tests/Wisp.App.Tests/LapReviewPlotValueTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewPlotValueTests.cs
@@ -116,6 +116,89 @@ public void LastReferenceSampleAndClampedFractionRemainFinite()
}, 0)!.Value, 8);
}
+ [Fact]
+ public void SameStoredLapDoesNotShowADuplicateReferencePath()
+ {
+ var data = DistinctData();
+ Assert.True(data.HasDistinctReference);
+ var original = data.Lap!;
+ Assert.False((data with { Reference = original with { Points = original.Points.ToArray() } }).HasDistinctReference);
+ Assert.False((data with { Comparison = data.Comparison! with { CanCompare = false } }).HasDistinctReference);
+ Assert.False((data with { Reference = null }).HasDistinctReference);
+ Assert.False((data with { Reference = original with { Points = [] } }).HasDistinctReference);
+ Assert.True((data with { Reference = original with { Number = original.Number + 1 } }).HasDistinctReference);
+ Assert.True((data with { Reference = original with { TimingMode = LapTimingMode.TimeAttack } }).HasDistinctReference);
+ Assert.True((data with
+ {
+ Reference = original with { Points = [original.Points[0] with { SampleIndex = 10 }, original.Points[1]] }
+ }).HasDistinctReference);
+ }
+
+ [Fact]
+ public void ReferencePositionUsesMatchedTimeAcrossAllThreeAxes()
+ {
+ var data = DistinctData();
+ Assert.Equal(new LapPosition(20, 30, 40), LapReviewPlot.ReferencePosition(data, 0));
+ Assert.Equal(new LapPosition(10, 20, 30), LapReviewPlot.ReferencePosition(At(0, 0), 0));
+ Assert.Equal(new LapPosition(30, 40, 50), LapReviewPlot.ReferencePosition(At(1, 1), 0));
+ Assert.Equal(new LapPosition(30, 40, 50), LapReviewPlot.ReferencePosition(At(1, 0), 0));
+
+ LapReviewPlotData At(double seconds, int lower) => data with
+ { Comparison = data.Comparison! with { Points = [new(0, 0, seconds, 0, lower)] } };
+ }
+
+ [Theory]
+ [InlineData(double.NaN, 0)]
+ [InlineData(double.PositiveInfinity, 0)]
+ [InlineData(-.1, 0)]
+ [InlineData(1.1, 0)]
+ [InlineData(.5, -1)]
+ [InlineData(.5, 2)]
+ [InlineData(2, 1)]
+ public void ReferencePositionRejectsInvalidOrExtrapolatedMatches(double seconds, int lower)
+ {
+ var data = DistinctData();
+ Assert.Null(LapReviewPlot.ReferencePosition(data with
+ { Comparison = data.Comparison! with { Points = [new(0, 0, seconds, 0, lower)] } }, 0));
+ }
+
+ [Fact]
+ public void ReferencePositionDoesNotCrossGapsOrNonFinitePositions()
+ {
+ var data = DistinctData();
+ var points = data.Reference!.Points;
+ foreach (var upper in new[]
+ {
+ points[1] with { BreakBefore = true },
+ points[1] with { LapSeconds = points[0].LapSeconds },
+ points[1] with { LapSeconds = double.NaN },
+ points[1] with { Position = new(30, float.NaN, 50) }
+ })
+ Assert.Null(LapReviewPlot.ReferencePosition(data with
+ { Reference = data.Reference with { Points = [points[0], upper] } }, 0));
+ Assert.Null(LapReviewPlot.ReferencePosition(data with
+ { Reference = data.Reference with { Points = [points[0] with { Position = new(float.PositiveInfinity, 20, 30) }, points[1]] } }, 0));
+ Assert.Null(LapReviewPlot.ReferencePosition(data, -1));
+ Assert.Null(LapReviewPlot.ReferencePosition(data, 2));
+ Assert.Null(LapReviewPlot.ReferencePosition(data with { Comparison = data.Comparison! with { CanCompare = false } }, 0));
+ Assert.Null(LapReviewPlot.ReferencePosition(data with { Reference = data.Lap }, 0));
+ }
+
+ private static LapReviewPlotData DistinctData()
+ {
+ var data = Data(LapReviewChannel.Speed);
+ var lap = data.Lap! with { RunId = Guid.Parse("81e8316f-b849-49aa-a092-7cebe761057a"), Number = 1 };
+ return data with
+ {
+ Lap = lap,
+ Reference = lap with
+ {
+ RunId = Guid.Parse("044e2a9e-75f3-468b-981b-8a3c6fa3434e"),
+ Points = [lap.Points[0] with { Position = new(10, 20, 30) }, lap.Points[1] with { Position = new(30, 40, 50) }]
+ }
+ };
+ }
+
private static LapReviewPlotData Data(LapReviewChannel channel)
{
var a = RunTestData.State() with { GroundSpeedMetersPerSecond = 10, Gear = TransmissionGear.Third, Brake = 0 };
diff --git a/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs b/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
new file mode 100644
index 0000000..518fdf3
--- /dev/null
+++ b/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
@@ -0,0 +1,96 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Threading;
+using Wisp.App.Runs;
+using Wisp.UiReview;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+internal static class LapReviewScrubbingTests
+{
+ internal static void AssertOnCurrentDispatcher()
+ {
+ var previousContext = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
+ try { AssertScrubbing(); }
+ finally { SynchronizationContext.SetSynchronizationContext(previousContext); }
+ }
+
+ private static void AssertScrubbing()
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
+ Await(LapReviewComparisonTestSupport.Ready(model));
+ var view = new LapReviewView { DataContext = model };
+ var slider = Assert.IsType(view.FindName("LapCursorSlider"));
+ view.Measure(new Size(1000, 800)); view.Arrange(new Rect(0, 0, 1000, 800));
+ ReadyBindings(view, slider);
+ Assert.Equal((double)model.MaximumCursor, slider.Maximum);
+ Assert.Equal((double)model.Cursor, slider.Value);
+ try
+ {
+ var updates = view.ScrubUpdates;
+ slider.RaiseEvent(new DragStartedEventArgs(0, 0) { RoutedEvent = Thumb.DragStartedEvent });
+ for (var value = 1; value <= 20; value++) slider.SetCurrentValue(RangeBase.ValueProperty, (double)value);
+ Assert.Equal(0, model.Cursor);
+ Assert.Equal(updates, view.ScrubUpdates);
+ view.FlushScrub();
+ Assert.Equal(20, model.Cursor);
+ Assert.Equal(updates + 1, view.ScrubUpdates);
+ for (var value = 21; value <= 30; value++) slider.SetCurrentValue(RangeBase.ValueProperty, (double)value);
+ Assert.Equal(20, model.Cursor);
+ slider.RaiseEvent(new DragCompletedEventArgs(0, 0, false) { RoutedEvent = Thumb.DragCompletedEvent });
+ Assert.Equal(30, model.Cursor);
+ Assert.Equal(updates + 2, view.ScrubUpdates);
+
+ slider.SetCurrentValue(RangeBase.ValueProperty, 40d);
+ Assert.Equal(40, model.Cursor);
+ Slider.IncreaseSmall.Execute(null, slider);
+ Assert.Equal(41, model.Cursor);
+ model.Cursor = 45;
+ Assert.Equal(45d, slider.Value);
+
+ view.BeginScrub();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 55d);
+ view.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent));
+ Assert.Equal(55, model.Cursor);
+
+ using var next = new LapReviewComparisonTestSupport();
+ next.Model.SetRuns(LapReviewFixtures.Create(), null);
+ Await(LapReviewComparisonTestSupport.Ready(next.Model));
+ view.BeginScrub();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 75d);
+ view.DataContext = next.Model;
+ ReadyBindings(view, slider);
+ view.FlushScrub();
+ Assert.Equal(55, model.Cursor);
+ Assert.Equal(0, next.Model.Cursor);
+ Assert.Equal(0d, slider.Value);
+ Assert.Null(PresentationSource.FromVisual(view));
+ }
+ finally { view.EndScrub(); view.DataContext = null; }
+ }
+
+ private static void ReadyBindings(LapReviewView view, Slider slider)
+ {
+ Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.DataBind);
+ slider.GetBindingExpression(RangeBase.MaximumProperty)?.UpdateTarget();
+ slider.GetBindingExpression(RangeBase.ValueProperty)?.UpdateTarget();
+ view.UpdateLayout();
+ }
+
+ private static void Await(Task task)
+ {
+ if (!task.IsCompleted)
+ {
+ var frame = new DispatcherFrame();
+ var dispatcher = Dispatcher.CurrentDispatcher;
+ _ = task.ContinueWith(_ => dispatcher.BeginInvoke(new Action(() => frame.Continue = false)), TaskScheduler.Default);
+ Dispatcher.PushFrame(frame);
+ }
+ task.GetAwaiter().GetResult();
+ }
+}
diff --git a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
index 65ed690..d5fe87a 100644
--- a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
@@ -38,6 +38,40 @@ public void CursorAndCameraUpdatesReuseThePreparedGeometry() => OnSta(async () =
Assert.Equal(original, track.ProjectPoint(1));
});
+ [Fact]
+ public void ExplicitZoomOutRequestsOverviewButResetAndCameraBindingDoNot() => OnSta(async () =>
+ {
+ var track = Track(); await track.PrepareForTestAsync();
+ var requests = 0; track.ZoomOutAtOverview += (_, _) => requests++;
+ track.ZoomBy(4); track.ZoomBy(.5);
+ Assert.Equal(0, requests);
+ track.ZoomBy(.5);
+ Assert.Equal(1, requests);
+ track.ZoomBy(.9);
+ Assert.Equal(2, requests);
+ track.ZoomFactor = .4; track.ResetView();
+ track.ZoomBy(double.NaN); track.ZoomBy(0); track.ZoomBy(1);
+ Assert.Equal(2, requests);
+ Assert.Equal(1, track.SceneBuildCount);
+ });
+
+ [Fact]
+ public void ComparisonRangeAndPathChangesRebuildWhileMatchedCursorUpdatesDoNot() => OnSta(async () =>
+ {
+ var track = Track();
+ var reference = Data([new(0, 0, 0), new(40, 30, 50), new(80, 35, 40)]).Lap! with { RunId = Guid.NewGuid() };
+ track.Data = track.Data! with { Reference = reference, Comparison = new([], 1, true, string.Empty) };
+ await track.PrepareForTestAsync();
+ track.Data = track.Data! with { ColorRangeOverride = new(0, 100, true) };
+ await track.PrepareForTestAsync();
+ Assert.Equal(2, track.SceneBuildCount);
+ track.Data = track.Data! with { ShowReferencePath = false };
+ await track.PrepareForTestAsync();
+ Assert.Equal(3, track.SceneBuildCount);
+ for (var i = 0; i < 100; i++) track.Data = track.Data! with { Cursor = i % 3 };
+ Assert.Equal(3, track.SceneBuildCount); Assert.True(track.IsReady);
+ });
+
[Fact]
public void RapidChannelReturnCancelsTheStaleBuildAndRestoresTheCachedScene() => OnSta(async () =>
{
@@ -167,6 +201,123 @@ private static void AssertPixel(FrameworkElement surface, Point anchor, Color ex
Assert.Equal(255, pixel[3]);
}
+ [Fact]
+ public void InterpolatedCursorMovesWithoutMovingContactsOrRebuildingGeometry() => OnSta(async () =>
+ {
+ var data = Data([new(-100, 0, 0), new(0, 0, 0), new(100, 0, 0)]) with
+ { Cursor = 1, Contacts = [new(1, 1, 100, LapReviewContactKind.PossibleContact)] };
+ var track = new LapReviewTrack3D { Data = data };
+ var surface = new Border { Background = Brushes.Black, Child = track };
+ surface.Measure(new Size(800, 400)); surface.Arrange(new Rect(0, 0, 800, 400));
+ await track.PrepareForTestAsync();
+ track.Yaw = 0; track.Pitch = 90; track.Roll = 0;
+ var sample = track.ProjectPoint(1);
+ var interpolated = sample + (track.ProjectPoint(2) - sample) * .4;
+ track.Data = data with { CursorPositionOverride = new(40, 0, 0) };
+ AssertPixel(surface, interpolated, Colors.White);
+ AssertPixel(surface, sample, ((SolidColorBrush)LapReviewPalette.ContactBrush).Color);
+ AssertPixel(surface, new(sample.X + 10, sample.Y), ((SolidColorBrush)LapReviewPalette.GetBrush(.5)).Color);
+ for (var i = 0; i < 100; i++) track.Data = data with { CursorPositionOverride = new(i, 0, 0) };
+ Assert.Equal(1, track.SceneBuildCount); Assert.True(track.IsReady);
+ });
+
+ [Fact]
+ public void ComparisonModelsShareOneSceneAndClickingEitherTrackFocusesWithoutRemovingTheOther() => OnSta(async () =>
+ {
+ var track = ComparisonTrack(); await track.PrepareForTestAsync();
+ Assert.True(track.IsComparison); Assert.True(track.HasBothPreparedModels);
+ Assert.Equal(2, track.SceneBuildCount); Assert.Equal(3, track.PreparedReferenceSegmentCount);
+ var overviewWidth = Enumerable.Range(0, 4).Max(i => track.ProjectReferencePoint(i).X) - Enumerable.Range(0, 4).Min(i => track.ProjectReferencePoint(i).X);
+ Assert.True(Enumerable.Range(0, 4).Max(i => track.ProjectPoint(i).X) < Enumerable.Range(0, 4).Min(i => track.ProjectReferencePoint(i).X));
+ var primary = new List(); var reference = new List();
+ track.PointChosen += primary.Add; track.ReferencePointChosen += reference.Add;
+ track.Choose(track.ProjectReferencePoint(1));
+ Assert.Equal(1, Assert.Single(reference)); Assert.Empty(primary); Assert.Equal(2, track.FocusedLap);
+ Assert.True(track.IsCameraMotionActive); track.CompleteCameraMotionForTest();
+ var focusedWidth = Enumerable.Range(0, 4).Max(i => track.ProjectReferencePoint(i).X) - Enumerable.Range(0, 4).Min(i => track.ProjectReferencePoint(i).X);
+ Assert.True(focusedWidth > overviewWidth * 1.15);
+ Assert.True(track.HasBothPreparedModels); Assert.Equal(2, track.SceneBuildCount);
+ Assert.True(SendKey(track, Key.Right).Handled); Assert.Equal(1, reference[^1]);
+ track.ShowAll(); track.CompleteCameraMotionForTest();
+ track.Choose(track.ProjectPoint(2)); track.CompleteCameraMotionForTest();
+ Assert.Equal(2, Assert.Single(primary)); Assert.Equal(1, track.FocusedLap);
+ Assert.True(track.HasBothPreparedModels); Assert.Equal(2, track.SceneBuildCount);
+ });
+
+ [Fact]
+ public void RotatedOverviewFitsBothTracksAndCursorUpdatesDoNotPrepareNewMeshes() => OnSta(async () =>
+ {
+ var track = ComparisonTrack(); await track.PrepareForTestAsync();
+ for (var i = 0; i < 100; i++)
+ {
+ track.Data = track.Data! with { Cursor = i % 4 };
+ track.ComparisonData = track.ComparisonData! with { Cursor = (i + 1) % 4 };
+ }
+ Assert.Equal(2, track.SceneBuildCount);
+ track.Yaw = 123; track.Pitch = 47; track.Roll = 78; track.ZoomBy(3); track.PanBy(400, -250);
+ track.ShowAll(); track.CompleteCameraMotionForTest();
+ for (var i = 0; i < 4; i++)
+ foreach (var point in new[] { track.ProjectPoint(i), track.ProjectReferencePoint(i) })
+ {
+ Assert.InRange(point.X, 0, track.ActualWidth);
+ Assert.InRange(point.Y, 0, track.ActualHeight);
+ }
+ Assert.Equal(0, track.FocusedLap); Assert.Equal(123, track.Yaw); Assert.Equal(78, track.Roll);
+ track.FocusLap(2); track.CompleteCameraMotionForTest(); track.ZoomBy(.1);
+ Assert.Equal(0, track.FocusedLap); Assert.True(track.HasBothPreparedModels);
+ Assert.Equal(2, track.SceneBuildCount);
+ });
+
+ [Fact]
+ public void SelectingAnotherPointOnTheFocusedLapPreservesManualCameraAdjustments() => OnSta(async () =>
+ {
+ var track = ComparisonTrack(); await track.PrepareForTestAsync();
+ track.Choose(track.ProjectPoint(0));
+ Assert.Equal(1, track.FocusedLap); Assert.True(track.IsCameraMotionActive);
+ track.CompleteCameraMotionForTest();
+ track.Yaw += 12; track.Pitch = 35; track.Roll = 8;
+ track.ZoomBy(2); track.PanBy(18, -7);
+ var zoom = track.ZoomFactor; var projection = track.ProjectPoint(1);
+ track.Choose(projection);
+ Assert.Equal(1, track.FocusedLap); Assert.False(track.IsCameraMotionActive);
+ Assert.Equal(zoom, track.ZoomFactor); Assert.Equal(projection, track.ProjectPoint(1));
+ track.Choose(track.ProjectReferencePoint(1));
+ Assert.Equal(2, track.FocusedLap); Assert.True(track.IsCameraMotionActive);
+ track.CompleteCameraMotionForTest();
+ Assert.True(track.HasBothPreparedModels); Assert.Equal(2, track.SceneBuildCount);
+ });
+
+ [Fact]
+ public void RemovingComparisonAndUnloadingCancelCameraMotionAndKeepPrimaryGeometryReusable() => OnSta(async () =>
+ {
+ var track = ComparisonTrack(); var other = track.ComparisonData;
+ await track.PrepareForTestAsync();
+ track.FocusLap(2); Assert.True(track.IsCameraMotionActive);
+ track.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent));
+ Assert.False(track.IsCameraMotionActive);
+ track.ComparisonData = null; await track.PrepareForTestAsync();
+ Assert.False(track.IsComparison); Assert.False(track.HasBothPreparedModels);
+ Assert.True(track.IsReady); Assert.Equal(2, track.SceneBuildCount);
+ track.ComparisonData = other; await track.PrepareForTestAsync();
+ Assert.True(track.IsComparison); Assert.True(track.HasBothPreparedModels); Assert.Equal(3, track.SceneBuildCount);
+ track.FocusLap(1); track.Data = null;
+ Assert.False(track.IsCameraMotionActive); Assert.False(track.IsReady);
+ });
+
+ private static LapReviewTrack3D ComparisonTrack()
+ {
+ var first = Data([new(-100, 0, 0), new(-50, 15, 30), new(0, 30, 70), new(100, 5, 0)]);
+ var second = Data([new(-100, 1, 0), new(-40, 20, 35), new(0, 35, 75), new(100, 10, 0)]);
+ second = second with { Lap = second.Lap! with { RunId = Guid.NewGuid() } };
+ var track = new LapReviewTrack3D
+ {
+ Data = first with { Reference = second.Lap, Comparison = new([], 1, true, string.Empty), ShowReferencePath = false },
+ ComparisonData = second with { Reference = first.Lap, Comparison = new([], 1, true, string.Empty), ShowReferencePath = false }
+ };
+ track.Measure(new Size(800, 400)); track.Arrange(new Rect(0, 0, 800, 400));
+ return track;
+ }
+
private static LapReviewTrack3D Track()
{
var track = new LapReviewTrack3D { Data = Data([new(0, 0, 0), new(20, 10, 30), new(60, 15, 10)]) };
diff --git a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
index 2899610..fb73388 100644
--- a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
@@ -28,11 +28,47 @@ public void NormalizationPreservesActualElevationAndUsesTheSameScaleForEveryAxis
public void OnlyAnAcceptedReferenceCanExpandTheBounds()
{
var data = Data([new(0, 0, 0), new(10, 1, 0)]);
- var reference = Data([new(0, 0, 0), new(1000, 100, 0)]).Lap;
+ var reference = Data([new(0, 0, 0), new(1000, 100, 0)]).Lap! with { RunId = Guid.NewGuid() };
Assert.Equal(10, LapReviewTrackBounds.From(data with { Reference = reference }).Span);
Assert.Equal(1000, LapReviewTrackBounds.From(data with { Reference = reference, Comparison = new([], 1, true, string.Empty) }).Span);
}
+ [Fact]
+ public void SeparateComparisonViewsKeepUnionBoundsWithoutDrawingTheOtherLapsPath()
+ {
+ var first = Data([new(0, 0, 0), new(100, 10, 50)]);
+ var second = Data([new(0, 0, 0), new(1000, 100, 0)]).Lap! with { RunId = Guid.NewGuid() };
+ var combined = first with { Reference = second, Comparison = new([], 1, true, string.Empty) };
+ var split = combined with { ShowReferencePath = false };
+ var overlay = LapReviewTrackGeometry.Build(combined, TestContext.Current.CancellationToken);
+ var separate = LapReviewTrackGeometry.Build(split, TestContext.Current.CancellationToken);
+ Assert.Equal(overlay.Bounds, separate.Bounds);
+ Assert.Equal(overlay.Fit, separate.Fit);
+ Assert.Equal(1000, separate.Bounds.Span);
+ Assert.Equal(overlay.Model.Children.Count - 1, separate.Model.Children.Count);
+ Assert.Equal(overlay.SegmentCount, separate.SegmentCount);
+
+ var sameLap = first with { Reference = first.Lap! with { }, Comparison = new([], 1, true, string.Empty) };
+ Assert.False(sameLap.HasDistinctReference);
+ Assert.Equal(LapReviewTrackBounds.From(first), LapReviewTrackBounds.From(sameLap));
+ }
+
+ [Fact]
+ public void SideBySidePlacementPreservesEqualScaleAndTrueElevationInOneCoordinateSystem()
+ {
+ var first = Data([new(0, 20, 0), new(100, 35, 50)]);
+ var second = Data([new(0, 40, 0), new(200, 80, 75)]);
+ var bounds = LapReviewTrackBounds.From(first, second.Lap);
+ var placement = LapReviewTrackArrangement.Create(first, second, bounds);
+ Assert.Equal(0, placement.PrimaryOffset.Y); Assert.Equal(0, placement.ReferenceOffset.Y);
+ var a = first.Lap!.Points.Select(point => bounds.Normalize(point.Position) + placement.PrimaryOffset).ToArray();
+ var b = second.Lap!.Points.Select(point => bounds.Normalize(point.Position) + placement.ReferenceOffset).ToArray();
+ Assert.Equal(2, (b[1].X - b[0].X) / (a[1].X - a[0].X), 8);
+ Assert.Equal(15, (a[1].Y - a[0].Y) * bounds.Span, 8);
+ Assert.Equal(40, (b[1].Y - b[0].Y) * bounds.Span, 8);
+ Assert.Equal(8, placement.PrimaryCorners.Length); Assert.Equal(8, placement.ReferenceCorners.Length);
+ }
+
[Fact]
public void NonFinitePositionsDoNotPoisonBoundsOrCreateABridge()
{
diff --git a/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs b/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs
index 5c74b10..07a5852 100644
--- a/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs
+++ b/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs
@@ -112,6 +112,7 @@ void Check(string name, Action action)
Check(nameof(OverlayGForcePlacementTests), OverlayGForcePlacementTests.AssertOnCurrentDispatcher);
Check(nameof(HudProfilePlacementTests), HudProfilePlacementTests.AssertOnCurrentDispatcher);
Check(nameof(LapReviewUiRuntimeTests), LapReviewUiRuntimeTests.AssertOnCurrentDispatcher);
+ Check(nameof(LapReviewScrubbingTests), LapReviewScrubbingTests.AssertOnCurrentDispatcher);
Check(nameof(OverlayElectricGaugePlacementTests), OverlayElectricGaugePlacementTests.AssertOnCurrentDispatcher);
Check("NativeRendererIntegrationTests hardware", () => NativeRendererIntegrationTests.AssertOnCurrentDispatcher());
Check("NativeRendererIntegrationTests WARP", () => NativeRendererIntegrationTests.AssertOnCurrentDispatcher(cpuRendering: true));
diff --git a/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs b/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs
index af89142..b7bcfff 100644
--- a/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs
+++ b/tools/Wisp.UiReview/LapReview3DPerformanceReview.cs
@@ -4,7 +4,10 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Windows;
+using System.Windows.Automation;
using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Data;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -13,6 +16,7 @@
using Wisp.App.Runs;
using Wisp.Core;
using Wisp.Core.Runs;
+using Wisp.Telemetry;
namespace Wisp.UiReview;
@@ -20,13 +24,15 @@ internal static class LapReview3DPerformanceReview
{
internal static int Run(string source, string output, Func loadResources)
{
- using var deadline = new System.Threading.Timer(_ => Environment.Exit(124), null, TimeSpan.FromSeconds(120), Timeout.InfiniteTimeSpan);
+ using var deadline = new System.Threading.Timer(_ => Environment.Exit(124), null, TimeSpan.FromSeconds(180), Timeout.InfiniteTimeSpan);
using var bindings = new BindingTrace();
var previousContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
var application = new ResourceApplication { ShutdownMode = ShutdownMode.OnExplicitShutdown };
var failures = new List();
var measurements = new List();
+ var scrubMeasurements = new List();
+ var dragBatching = new List();
string? sourceSha256 = null;
var sourceSamples = 0; var lapPoints = 0; var maximumPoints = 0; var realSegments = 0; var stressSegments = 0;
double elevationSpan = 0;
@@ -89,6 +95,7 @@ internal static int Run(string source, string output, Func l
track.Yaw = -65; track.Pitch = 18; track.Roll = 5; track.ZoomFactor = 1;
Capture(surface, "recorded-3d-rotated");
CaptureActualView(run, Path.GetDirectoryName(path)!);
+ MeasureActualRunsPageScrubbing(run);
// Dense interpolation of this recorded geometry exercises the accepted size limit;
// it is a synthetic stress case, not another measured gameplay recording.
@@ -124,7 +131,9 @@ internal static int Run(string source, string output, Func l
if (bindings.TotalCount != 0) failures.Add("binding-diagnostics");
File.WriteAllText(Path.Combine(output, "lap-review-3d-performance.json"), JsonSerializer.Serialize(new
{
- method = "Read-only stored lap analysis and actual WPF 3D scene. Detached 1280x720 software rendering and the production Lap Review view at 1280x900; its map PNG uses the production bounded 2x export. No game, window, controller, UDP listener or settings access. Camera/cursor timings measure CPU property updates, not rendered or displayed FPS. Allocation deltas are process-wide managed allocations. The 180000-point case is dense interpolation of recorded geometry, not gameplay.",
+ method = "Read-only stored lap analysis and actual WPF 3D scene. Detached 1280x720 software rendering and the production Lap Review view at 1280x900; its map PNG uses the production bounded 2x export. RunsPage scrub measurements use the loaded recorded run through RunsViewModel and the actual Lap position cursor Slider binding, with DataBind/Render dispatcher work and layout flushed after each change. They are programmatic slider changes, not physical mouse input. Separate bounded samples rasterize the actual page in software. No game, window, controller, UDP listener or settings-file access. Timings measure CPU work and elapsed time, not displayed FPS. Allocation deltas are process-wide managed allocations. The 180000-point case is dense interpolation of recorded geometry, not gameplay.",
+ appAssemblySha256 = Hash(typeof(RunsPage).Assembly.Location),
+ coreAssemblySha256 = Hash(typeof(RecordedRun).Assembly.Location),
sourceSha256,
sourceSamples,
lapPoints,
@@ -138,6 +147,8 @@ internal static int Run(string source, string output, Func l
note = "Only production contact analysis and original stored run markers are used. No synthetic contact markers are added. PossibleContact entries are estimates, not confirmed collisions."
},
measurements,
+ scrubMeasurements,
+ dragBatching,
failures,
bindingDiagnosticCount = bindings.TotalCount
}, new JsonSerializerOptions { WriteIndented = true }));
@@ -226,6 +237,155 @@ void CaptureActualView(RecordedRun run, string runDirectory)
Await(actualTrack.PrepareForTestAsync());
}
}
+
+ void MeasureActualRunsPageScrubbing(RecordedRun run)
+ {
+ var receiver = new TelemetryUdpReceiver(); // Never started.
+ var service = new RunRecordingService(receiver, Path.Combine(output, "unused-scrub-library"));
+ var settings = new AppSettings { StartWithWindows = false, StartWithForza = false, AutomaticApplicationUpdateChecks = false };
+ var model = new RunsViewModel(service, settings, Dispatcher.CurrentDispatcher);
+ var page = new RunsPage { DataContext = model };
+ var surface = new Border
+ {
+ Padding = new Thickness(16),
+ Background = (Brush)application.FindResource("WindowBrush"),
+ DataContext = new DiagnosticsViewModel(settings),
+ Child = page
+ };
+ LapReviewTrack3D? actualTrack = null;
+ try
+ {
+ VisualTreeHelper.SetRootDpi(surface, new DpiScale(1, 1));
+ Await(model.ShowReviewAsync(run));
+ WaitForReview();
+ var expander = (Expander)page.FindName("LapReviewExpander");
+ expander.IsExpanded = true;
+ var size = new Size(1280, 900);
+ LapReviewUiChecks.Arrange(surface, size);
+ var view = Descendants(expander).OfType().Single();
+ actualTrack = (LapReviewTrack3D)view.FindName("Track3D");
+ var slider = Descendants(view).OfType().Single(item =>
+ AutomationProperties.GetName(item) == "Lap position cursor");
+ var review = model.LapReview;
+ Check(BindingOperations.IsDataBound(slider, RangeBase.ValueProperty), "scrub-production-slider-bound");
+ Check(review.Lap is { Points.Length: > 1 }, "scrub-production-lap-ready");
+ var scroll = (ScrollViewer)page.FindName("RunsScroll");
+ var toolbar = (FrameworkElement)view.FindName("MapToolbar");
+ using var process = Process.GetCurrentProcess();
+ foreach (var is3D in new[] { false, true })
+ {
+ review.Is3D = is3D;
+ LapReviewUiChecks.Arrange(surface, size);
+ if (is3D) Await(actualTrack.PrepareForTestAsync());
+ WaitForReview();
+ if (scroll.Content is UIElement scrollContent)
+ scroll.ScrollToVerticalOffset(Math.Max(0, toolbar.TranslatePoint(new Point(), scrollContent).Y - 8));
+ LapReviewUiChecks.Arrange(surface, size);
+ for (var warm = 0; warm < 8; warm++) SetCursor(warm * review.MaximumCursor / 7);
+ var mode = is3D ? "3d" : "2d";
+ Scrub(mode, 120, render: false);
+ Scrub(mode, 240, render: false);
+ Scrub(mode, 12, render: true);
+ CheckDragBatching(mode);
+ }
+ Check(!receiver.IsRunning && !service.IsRecording, "scrub-no-telemetry-or-recording-started");
+ Check(PresentationSource.FromVisual(surface) is null && application.Windows.Count == 0, "scrub-no-presentation-window");
+
+ void Scrub(string mode, int changes, bool render)
+ {
+ var bitmap = render ? new RenderTargetBitmap(1280, 900, 96, 96, PixelFormats.Pbgra32) : null;
+ var durations = new double[changes];
+ var buildsBefore = actualTrack.SceneBuildCount;
+ var allocated = GC.GetTotalAllocatedBytes(precise: true);
+ var cpu = process.TotalProcessorTime;
+ var timer = Stopwatch.StartNew();
+ for (var index = 0; index < changes; index++)
+ {
+ var began = Stopwatch.GetTimestamp();
+ SetCursor(index * review.MaximumCursor / (changes - 1));
+ bitmap?.Render(surface);
+ durations[index] = Stopwatch.GetElapsedTime(began).TotalMilliseconds;
+ }
+ timer.Stop();
+ var cpuMilliseconds = (process.TotalProcessorTime - cpu).TotalMilliseconds;
+ var allocatedBytes = GC.GetTotalAllocatedBytes(precise: true) - allocated;
+ var buildsAfter = actualTrack.SceneBuildCount;
+ Array.Sort(durations);
+ scrubMeasurements.Add(new(mode, changes, render, review.Lap!.Points.Length,
+ timer.Elapsed.TotalMilliseconds, cpuMilliseconds, durations[changes / 2],
+ durations[(int)Math.Ceiling(changes * .95) - 1], durations[^1], allocatedBytes,
+ buildsBefore, buildsAfter, review.Cursor, (int)slider.Value));
+ Check(review.Cursor == review.MaximumCursor && (int)slider.Value == review.Cursor,
+ $"scrub-{mode}-{changes}-slider-reached-model");
+ Check(buildsBefore == buildsAfter, $"scrub-{mode}-{changes}-keeps-3d-scene");
+ if (bitmap is not null)
+ {
+ bitmap.Freeze();
+ Await(RunImageExporter.WriteAsync(bitmap, Path.Combine(output, $"actual-runs-page-scrub-{mode}.png")));
+ }
+ }
+ void SetCursor(int value)
+ {
+ // Preserve the production two-way binding and exercise its normal
+ // ValueChanged path, parent cursor synchronization and all readouts.
+ slider.SetCurrentValue(RangeBase.ValueProperty, (double)value);
+ Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.DataBind);
+ surface.UpdateLayout();
+ Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.Render);
+ surface.UpdateLayout();
+ }
+ void CheckDragBatching(string mode)
+ {
+ SetCursor(0);
+ var before = view.ScrubUpdates;
+ var builds = actualTrack.SceneBuildCount;
+ var timer = Stopwatch.StartNew();
+ view.BeginScrub();
+ try
+ {
+ for (var frame = 0; frame < 10; frame++)
+ {
+ for (var input = 0; input < 50; input++)
+ slider.SetCurrentValue(RangeBase.ValueProperty,
+ (double)((frame * 50 + input) * review.MaximumCursor / 499));
+ // Deterministic rendering-boundary simulation; not a displayed-frame measurement.
+ view.FlushScrub();
+ }
+ }
+ finally { view.EndScrub(); }
+ timer.Stop();
+ Check(view.ScrubUpdates - before == 10 && review.Cursor == review.MaximumCursor,
+ $"scrub-{mode}-500-inputs-coalesce-to-10-final-updates");
+ Check(actualTrack.SceneBuildCount == builds, $"scrub-{mode}-batched-drag-keeps-scene");
+ dragBatching.Add(new
+ {
+ mode,
+ inputChanges = 500,
+ simulatedFrameBoundaries = 10,
+ committedUpdates = view.ScrubUpdates - before,
+ finalCursor = review.Cursor,
+ elapsedMilliseconds = timer.Elapsed.TotalMilliseconds
+ });
+ }
+ }
+ finally
+ {
+ page.DataContext = null;
+ model.Dispose();
+ if (actualTrack is not null) { actualTrack.Data = null; Await(actualTrack.PrepareForTestAsync()); }
+ Await(service.DisposeAsync().AsTask());
+ Await(receiver.DisposeAsync().AsTask());
+ }
+ void WaitForReview()
+ {
+ var timer = Stopwatch.StartNew();
+ do { Await(Task.Delay(10)); }
+ while ((model.IsBusy || model.IsPreparingCharts || model.LapReview.IsBusy) && timer.Elapsed < TimeSpan.FromSeconds(20));
+ Dispatcher.CurrentDispatcher.Invoke(static () => { }, DispatcherPriority.ContextIdle);
+ if (model.IsBusy || model.IsPreparingCharts || model.LapReview.IsBusy || model.HasError)
+ throw new InvalidOperationException("The actual Runs page did not finish preparing its recorded lap.");
+ }
+ }
}
private static LapReviewLap Densify(LapReviewLap lap)
@@ -264,5 +424,23 @@ private static void Await(Task task)
task.GetAwaiter().GetResult();
}
private sealed record Measurement(string Name, int Operations, double TotalMilliseconds, long ManagedAllocatedBytes);
+ private sealed record ScrubMeasurement(string Mode, int SliderChanges, bool SoftwareRasterized, int RecordedLapPoints,
+ double TotalMilliseconds, double ProcessCpuMilliseconds, double MedianUpdateMilliseconds,
+ double P95UpdateMilliseconds, double MaximumUpdateMilliseconds, long ManagedAllocatedBytes,
+ int SceneBuildsBefore, int SceneBuildsAfter, int ModelCursor, int SliderCursor);
+ private static IEnumerable Descendants(DependencyObject root)
+ {
+ var pending = new Queue();
+ var seen = new HashSet();
+ pending.Enqueue(root);
+ while (pending.TryDequeue(out var item))
+ {
+ if (!seen.Add(item)) continue;
+ yield return item;
+ foreach (var child in LogicalTreeHelper.GetChildren(item).OfType()) pending.Enqueue(child);
+ for (var index = 0; item is Visual && index < VisualTreeHelper.GetChildrenCount(item); index++)
+ pending.Enqueue(VisualTreeHelper.GetChild(item, index));
+ }
+ }
private sealed class ResourceApplication : Application { protected override void OnStartup(StartupEventArgs e) { } }
}
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index a41d5e3..710296e 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -47,6 +47,11 @@ internal static void Run(string unusedStoreDirectory, Action check
var review = model.LapReview;
var track = (LapReviewPlot)view.FindName("Track");
var track3D = (LapReviewTrack3D)view.FindName("Track3D");
+ var mapViewport = (FrameworkElement)view.FindName("MapViewport");
+ var scrubBar = (FrameworkElement)view.FindName("ScrubBar");
+ var showBoth = (Button)view.FindName("ShowBothMaps");
+ var resetCamera = (Button)view.FindName("ResetCamera");
+ var cameraControls = (FrameworkElement)view.FindName("CameraControls");
var trace = (LapReviewPlot)view.FindName("Trace");
var view2D = (RadioButton)view.FindName("View2D");
var view3D = (RadioButton)view.FindName("View3D");
@@ -61,6 +66,7 @@ internal static void Run(string unusedStoreDirectory, Action check
var emptyReferenceHint = (TextBlock)view.FindName("EmptyReferenceHint");
var cursorReadout = (Border)view.FindName("CursorReadout");
var wheelReadings = (Expander)view.FindName("WheelReadings");
+ var lapSettings = Descendants(view).OfType().Single(item => Equals(item.Header, "Lap settings and benchmark"));
var slider = Descendants(view).OfType().Single(item => AutomationProperties.GetName(item) == "Lap position cursor");
check(review.Lap?.IsComplete == true && review.Reference?.IsComplete == true, "completed-A-and-B");
check(review.Plot.Comparison?.CanCompare == true, "comparison-ready");
@@ -82,7 +88,8 @@ internal static void Run(string unusedStoreDirectory, Action check
foreach (var (name, size) in new[] { ("normal", new Size(980, 750)), ("compact", new Size(720, 440)) })
{
model.ShowSummary(); Arrange(surface, size);
- ScrollTo(expander); Capture("controls");
+ lapSettings.IsExpanded = true;
+ ScrollTo(laps); Capture("controls");
foreach (var combo in combos) CheckCombo(combo);
var originalChannel = review.Channel;
channel.SetCurrentValue(Selector.SelectedIndexProperty, 0);
@@ -90,6 +97,8 @@ internal static void Run(string unusedStoreDirectory, Action check
SendKey(channel, Key.Down, surface); Pump();
check(channel.SelectedIndex == 1 && review.Channel.Channel == LapReviewChannel.Delta, name + "/combo-keyboard-selection");
channel.SetCurrentValue(Selector.SelectedItemProperty, originalChannel); Pump();
+ lapSettings.IsExpanded = false;
+ CheckMapAndScrubber(); Capture("map-and-scrubber");
slider.SetCurrentValue(RangeBase.ValueProperty, 120d); Pump();
check(review.Cursor == 120 && track.Data?.Cursor == 120 && trace.Data?.Cursor == 120, name + "/shared-slider-cursor");
@@ -100,6 +109,17 @@ internal static void Run(string unusedStoreDirectory, Action check
SendKey(track, Key.Home, surface); Pump();
check(review.Cursor == 0, name + "/map-home-cursor");
+ var beforeScrub = view.ScrubUpdates;
+ view.BeginScrub();
+ for (var cursor = 60; cursor < 120; cursor++) slider.SetCurrentValue(RangeBase.ValueProperty, (double)cursor);
+ check(review.Cursor == 0 && view.ScrubUpdates == beforeScrub, name + "/drag-coalesces-before-frame");
+ view.FlushScrub();
+ check(review.Cursor == 119 && view.ScrubUpdates == beforeScrub + 1, name + "/drag-flushes-latest-position");
+ slider.SetCurrentValue(RangeBase.ValueProperty, 120d);
+ view.EndScrub(); Pump();
+ check(review.Cursor == 120 && trace.Data?.Cursor == 120 && (int)slider.Value == 120,
+ name + "/drag-end-keeps-final-position");
+
review.Cursor = 125; review.SectionStartCommand.Execute(null); Ready(model);
review.Cursor = 265; review.SectionEndCommand.Execute(null); Ready(model);
check(review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265, name + "/section-commands");
@@ -124,12 +144,22 @@ internal static void Run(string unusedStoreDirectory, Action check
check(review.Is3D && view3D.IsChecked == true && track.Visibility == Visibility.Collapsed &&
track3D.Visibility == Visibility.Visible, name + "/3d-mode-binding");
check(track3D.IsReady && track3D.PreparedSegmentCount > 0, name + "/3d-scene-ready");
+ check(review.HasMapComparison && track3D.IsComparison && track3D.PreparedReferenceSegmentCount > 0 && track3D.FocusedLap == 0,
+ name + "/3d-comparison-opens-both-tracks-in-one-scene");
+ CheckMapAndScrubber();
+ check(Descendants(mapViewport).OfType().Count() == 1 &&
+ Descendants(mapViewport).OfType().Count() == 1,
+ name + "/3d-comparison-has-one-continuous-viewport");
check(ReferenceEquals(review.Lap, selectedLap) && ReferenceEquals(review.Reference, selectedReference) &&
ReferenceEquals(review.Channel, selectedChannel) && review.Cursor == selectedCursor &&
review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265, name + "/3d-keeps-review-state");
check(ReferenceEquals(track3D.Data?.Lap, review.Lap) && ReferenceEquals(track3D.Data?.Reference, review.Reference) &&
track3D.Data?.Cursor == review.Cursor && track3D.Data?.Channel == review.Channel.Channel,
name + "/3d-shares-recorded-data");
+ check(ReferenceEquals(track3D.ComparisonData?.Lap, review.Reference) && ReferenceEquals(track3D.ComparisonData?.Reference, review.Lap) &&
+ track3D.Data?.ShowReferencePath == false && track3D.ComparisonData?.ShowReferencePath == false,
+ name + "/3d-each-track-colors-its-own-lap");
+ CheckSharedColorScale();
var camera = (track3D.Yaw, track3D.Pitch, track3D.Roll, track3D.ZoomFactor);
track3D.Yaw = camera.Yaw + 35; track3D.Pitch = camera.Pitch + 8; track3D.Roll = camera.Roll + 12;
track3D.ZoomBy(1.2); track3D.PanBy(12, -8); Pump();
@@ -142,24 +172,75 @@ internal static void Run(string unusedStoreDirectory, Action check
check(review.Cursor == 181 && trace.Data?.Cursor == 181, name + "/3d-arrow-cursor");
foreach (var choice in review.Channels)
{
- channel.SetCurrentValue(Selector.SelectedItemProperty, choice); Pump(); Await(track3D.PrepareForTestAsync());
- check(track3D.IsReady && track3D.Data?.Channel == choice.Channel && trace.Data?.Channel == choice.Channel,
+ channel.SetCurrentValue(Selector.SelectedItemProperty, choice); Pump();
+ Await(track3D.PrepareForTestAsync());
+ check(track3D.IsReady && track3D.Data?.Channel == choice.Channel &&
+ track3D.ComparisonData?.Channel == choice.Channel && trace.Data?.Channel == choice.Channel,
name + "/3d-channel-" + choice.Channel);
+ CheckSharedColorScale();
}
review.Channel = review.Channels.First(item => item.Channel == LapReviewChannel.TireTemperature);
review.SelectedWheel = 3; Pump();
- check(track3D.Data?.Wheel == 3 && trace.Data?.Wheel == 3, name + "/3d-retains-wheel-selection");
- review.SelectedWheel = 0; review.Channel = selectedChannel; Pump(); Await(track3D.PrepareForTestAsync());
+ check(track3D.Data?.Wheel == 3 && track3D.ComparisonData?.Wheel == 3 && trace.Data?.Wheel == 3, name + "/3d-retains-wheel-selection");
+ review.SelectedWheel = 0; review.Channel = selectedChannel; Pump();
+ Await(track3D.PrepareForTestAsync());
check(saveMap.IsEnabled && mapExportSurface.ActualWidth > 100 && mapExportSurface.ActualHeight > 100,
name + "/map-export-surface-ready");
- track3D.ResetView(); Pump();
+ track3D.ResetView(); track3D.CompleteCameraMotionForTest(); Pump();
check(track3D.Yaw == camera.Yaw && track3D.Pitch == camera.Pitch && track3D.Roll == camera.Roll &&
track3D.ZoomFactor == camera.ZoomFactor, name + "/3d-reset-camera");
ScrollTo(mapExportSurface); Capture("map-3d-brake-section");
- VerifyMapImage("3d", track3D);
+ VerifyMapImage("3d-overview", track3D);
+ var sceneBuilds = track3D.SceneBuildCount;
+ var segmentCounts = (track3D.PreparedSegmentCount, track3D.PreparedReferenceSegmentCount);
+ var overviewSize = track3D.RenderSize;
+ var overviewA = ProjectedTrackBounds(reference: false);
+ var overviewB = ProjectedTrackBounds(reference: true);
+ check(overviewA.Width > 10 && overviewB.Width > 10 &&
+ (overviewA.Right <= overviewB.Left || overviewB.Right <= overviewA.Left),
+ name + "/3d-overview-projects-tracks-side-by-side");
+ track3D.FocusLap(1); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
+ check(track3D.FocusedLap == 1 && track3D.RenderSize == overviewSize && showBoth.Visibility == Visibility.Visible &&
+ ProjectedTrackBounds(reference: false).Width > overviewA.Width * 1.15 &&
+ ReferenceEquals(cameraControls.DataContext, track3D), name + "/focus-A-moves-shared-camera");
+ CheckBothGeometriesRetained();
+ ScrollTo(scrubBar); Capture("map-3d-focus-A");
+ VerifyMapImage("3d-focus-A", track3D);
+ showBoth.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent)); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
+ check(track3D.FocusedLap == 0 &&
+ showBoth.Visibility == Visibility.Visible, name + "/show-both-restores-overview");
+ CheckBothGeometriesRetained();
+
+ track3D.FocusLap(2); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
+ check(track3D.FocusedLap == 2 && track3D.RenderSize == overviewSize &&
+ ProjectedTrackBounds(reference: true).Width > overviewB.Width * 1.15 &&
+ ReferenceEquals(cameraControls.DataContext, track3D), name + "/focus-B-moves-shared-camera");
+ CheckBothGeometriesRetained();
+ var referenceCursor = track3D.ComparisonData!.Cursor;
+ var nextReference = Math.Clamp(referenceCursor + 1, 0, review.Reference!.Points.Length - 1);
+ var expectedCursor = track3D.ComparisonData.Comparison?.Points.ElementAtOrDefault(nextReference)?.ReferencePointIndex;
+ SendKey(track3D, Key.Right, surface); Pump();
+ check(expectedCursor is { } matched && review.Cursor == matched && trace.Data?.Cursor == matched,
+ name + "/focused-reference-arrow-selects-corresponding-A-position");
+ ScrollTo(scrubBar); Capture("map-3d-focus-B");
+ VerifyMapImage("3d-focus-B", track3D);
+ var zoomOut = Descendants(cameraControls).OfType().Single(item => Equals(item.Content, "Zoom out"));
+ for (var zoom = 0; zoom < 12 && track3D.FocusedLap != 0; zoom++)
+ { zoomOut.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent)); track3D.CompleteCameraMotionForTest(); }
+ Arrange(surface, size);
+ check(track3D.FocusedLap == 0, name + "/zoom-out-reveals-both-tracks");
+ CheckBothGeometriesRetained();
+ track3D.FocusLap(2); track3D.CompleteCameraMotionForTest();
+ track3D.Yaw += 28; track3D.Pitch += 7; track3D.Roll = 11; track3D.ZoomBy(1.3);
+ resetCamera.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent)); track3D.CompleteCameraMotionForTest(); Pump();
+ check(track3D.FocusedLap == 0 && track3D.Yaw == camera.Yaw && track3D.Pitch == camera.Pitch &&
+ track3D.Roll == camera.Roll && track3D.ZoomFactor == camera.ZoomFactor,
+ name + "/reset-restores-whole-scene-camera");
+ CheckMapAndScrubber(); Capture("map-3d-comparison-overview");
view2D.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump(); Arrange(surface, size);
check(!review.Is3D && track.Visibility == Visibility.Visible && track3D.Visibility == Visibility.Collapsed &&
- track.Data?.Cursor == review.Cursor && review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265,
+ track.Data?.Cursor == review.Cursor &&
+ review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265,
name + "/2d-restores-with-shared-state");
var metrics = Descendants(view).OfType().Single(item => ReferenceEquals(item.ItemsSource, review.Metrics));
ScrollTo(metrics); Capture("section-metrics");
@@ -183,12 +264,49 @@ void ScrollTo(FrameworkElement element)
{
var bounds = track.TransformToAncestor(scroll).TransformBounds(new Rect(track.RenderSize));
check(bounds.Top >= -.5 && bounds.Bottom <= scroll.ViewportHeight + .5, name + "/whole-map-fits-aligned-viewport");
- check(name == "normal" ? Math.Abs(track.ActualHeight - 360) < .5 : track.ActualHeight is >= 180 and < 360,
- name + "/responsive-map-height");
+ check(mapViewport.ActualHeight is >= 100 and <= 360 && track.ActualHeight >= 60 &&
+ track.ActualHeight <= mapViewport.ActualHeight, name + "/responsive-map-keeps-renderable-height");
+ }
+ }
+ void CheckMapAndScrubber()
+ {
+ scroll.ScrollToHome();
+ Arrange(surface, size);
+ var sliderBounds = slider.TransformToAncestor(scroll).TransformBounds(new Rect(slider.RenderSize));
+ var mapsBounds = mapViewport.TransformToAncestor(scroll).TransformBounds(new Rect(mapViewport.RenderSize));
+ var visibleTogether = scroll.VerticalOffset <= .5 && sliderBounds.Top >= -.5 &&
+ sliderBounds.Bottom <= scroll.ViewportHeight + .5 && mapsBounds.Top >= sliderBounds.Bottom &&
+ mapsBounds.Bottom <= scroll.ViewportHeight + .5;
+ check(visibleTogether, name + "/slider-and-map-visible-at-scroll-top");
+ if (!visibleTogether)
+ Console.WriteLine($"{name}/map-at-top: offset={scroll.VerticalOffset:0.##}; viewport={scroll.ViewportHeight:0.##}; slider={sliderBounds.Top:0.##}..{sliderBounds.Bottom:0.##}; maps={mapsBounds.Top:0.##}..{mapsBounds.Bottom:0.##}; map-height={mapViewport.ActualHeight:0.##}; mode={(review.Is3D ? "3d" : "2d")}");
+ }
+ void CheckSharedColorScale()
+ {
+ var a = track3D.Data; var b = track3D.ComparisonData;
+ check(a is not null && b is not null && a.ColorRangeOverride is { } shared && b.ColorRangeOverride == shared &&
+ LapReviewColorRange.From(a) == LapReviewColorRange.From(b), name + "/A-B-share-value-color-range-" + review.Channel.Channel);
+ }
+ Rect ProjectedTrackBounds(bool reference)
+ {
+ var points = (reference ? track3D.ComparisonData : track3D.Data)!.Lap!.Points;
+ var bounds = Rect.Empty;
+ for (var index = 0; index < points.Length; index++)
+ {
+ var point = reference ? track3D.ProjectReferencePoint(index) : track3D.ProjectPoint(index);
+ if (double.IsFinite(point.X) && double.IsFinite(point.Y)) bounds.Union(point);
}
+ return bounds;
+ }
+ void CheckBothGeometriesRetained()
+ {
+ check(track3D.SceneBuildCount == sceneBuilds &&
+ (track3D.PreparedSegmentCount, track3D.PreparedReferenceSegmentCount) == segmentCounts &&
+ ReferenceEquals(track3D.Data?.Lap, review.Lap) && ReferenceEquals(track3D.ComparisonData?.Lap, review.Reference),
+ name + "/camera-focus-retains-both-prepared-tracks");
}
void Capture(string stage) { Arrange(surface, size); capture?.Invoke(name + "-" + stage, surface, size); }
- void VerifyMapImage(string mode, FrameworkElement map)
+ void VerifyMapImage(string mode, params FrameworkElement[] maps)
{
Arrange(surface, size);
var bitmap = view.CaptureMapImage();
@@ -200,12 +318,47 @@ void VerifyMapImage(string mode, FrameworkElement map)
var opaque = corners.Length == 4 && corners.All(alpha => alpha == 255);
check(opaque, code + "opaque-corners-without-layout-offset");
if (!opaque) Console.WriteLine($"{code}corner-alpha=[{string.Join(",", corners)}]; pixels={bitmap.PixelWidth}x{bitmap.PixelHeight}; surface={mapExportSurface.RenderSize.Width:R}x{mapExportSurface.RenderSize.Height:R}");
- var mapBounds = map.TransformToAncestor(mapExportSurface).TransformBounds(new Rect(map.RenderSize));
var legend = Descendants(mapExportSurface).OfType()
.Single(item => ReferenceEquals(item.Fill, LapReviewPalette.LegendBrush));
var legendBounds = legend.TransformToAncestor(mapExportSurface).TransformBounds(new Rect(legend.RenderSize));
- check(ColorfulPixels(pixels, bitmap, mapBounds, mapExportSurface.RenderSize) > 50, code + "contains-map-line");
- check(ColorfulPixels(pixels, bitmap, legendBounds, mapExportSurface.RenderSize) > 150, code + "contains-color-legend");
+ var mapPixelsPassed = true;
+ foreach (var map in maps)
+ {
+ var mapBounds = map.TransformToAncestor(mapExportSurface).TransformBounds(new Rect(map.RenderSize));
+ var count = ColorfulPixels(pixels, bitmap, mapBounds, mapExportSurface.RenderSize);
+ var passed = count > 50;
+ mapPixelsPassed &= passed;
+ check(passed, code + "contains-map-line-" + map.Name);
+ if (!passed)
+ Console.WriteLine($"{code}map={map.Name}; colorful-pixels={count}; bounds={mapBounds.X:R},{mapBounds.Y:R},{mapBounds.Width:R},{mapBounds.Height:R}");
+ }
+ if (mode == "3d-overview")
+ {
+ foreach (var reference in new[] { false, true })
+ {
+ var projectedBounds = ProjectedTrackBounds(reference);
+ projectedBounds.Inflate(3, 3);
+ var sceneBounds = track3D.TransformToAncestor(mapExportSurface).TransformBounds(projectedBounds);
+ var count = ColorfulPixels(pixels, bitmap, sceneBounds, mapExportSurface.RenderSize);
+ mapPixelsPassed &= count > 50;
+ check(count > 50, code + (reference ? "contains-reference-track" : "contains-current-track"));
+ if (count <= 50)
+ Console.WriteLine($"{code}track={(reference ? "B" : "A")}; colorful-pixels={count}; projected={sceneBounds}");
+ }
+ }
+ var legendPixels = ColorfulPixels(pixels, bitmap, legendBounds, mapExportSurface.RenderSize);
+ check(legendPixels > 150, code + "contains-color-legend");
+ if (!mapPixelsPassed || legendPixels <= 150 || !opaque)
+ {
+ var descendantBounds = VisualTreeHelper.GetDescendantBounds(mapExportSurface);
+ var clip = VisualTreeHelper.GetClip(mapExportSurface)?.Bounds;
+ Console.WriteLine($"{code}legend-pixels={legendPixels}; legend={legendBounds.X:R},{legendBounds.Y:R},{legendBounds.Width:R},{legendBounds.Height:R}; bitmap={bitmap.PixelWidth}x{bitmap.PixelHeight}; dpi={bitmap.DpiX:R},{bitmap.DpiY:R}; surface={mapExportSurface.ActualWidth:R}x{mapExportSurface.ActualHeight:R}; descendants={descendantBounds}; clip={clip}; scroll={scroll.VerticalOffset:R}; map-height={track.ActualHeight:R}");
+ var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(unusedStoreDirectory))!;
+ Directory.CreateDirectory(outputDirectory);
+ var fileName = $"lap-map-failure-{name}-{mode}-{Guid.NewGuid():N}.png";
+ Await(RunImageExporter.WriteAsync(bitmap, Path.Combine(outputDirectory, fileName)));
+ Console.WriteLine($"{code}exact-export-bitmap={fileName}");
+ }
if (name != "normal") return;
var parent = Path.GetDirectoryName(Path.GetFullPath(unusedStoreDirectory))!;
From 9ecd3742425d4f020d9dc66a0d9987e881c82fd7 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 20:35:37 -0500
Subject: [PATCH 05/15] Keep the original 3D lap view and make shared space
optional
---
installer/PrivateShiftCaptureGuide.txt | 5 +-
src/Wisp.App/Runs/LapReviewView.xaml | 24 +++---
src/Wisp.App/Runs/LapReviewView.xaml.cs | 2 +-
src/Wisp.App/Runs/LapReviewViewModel.Map.cs | 9 +-
.../Runs/LapReviewViewModel.MapComparison.cs | 24 ++++--
.../LapReviewMapComparisonTests.cs | 86 +++++++++++++++++++
tools/Wisp.UiReview/LapReviewUiChecks.cs | 29 ++++++-
7 files changed, 154 insertions(+), 25 deletions(-)
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index fe2ddbd..2d3cb86 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -13,7 +13,7 @@ Changes
- Read the color legend and the selected point's value next to the map.
- Show reported object contacts, possible impacts and saved manual markers.
- Save the current 2D or 3D map, including its legend, as a PNG image.
-- Compare two 3D laps in one continuous workspace, using the same channel and
+- Optionally compare two 3D laps in Shared space, using the same channel and
color scale. Click a track to move closer; zoom back out to see both.
- Keep the lap-position slider above the map and batch drag updates to one
per UI render cycle, preserving the final position when released.
@@ -24,7 +24,8 @@ Scroll: zoom. Double-click or Reset camera: restore the workspace camera.
Click the line: select a point. Arrow keys: move along the lap.
Home/End: first/last point. Camera controls also provide axis sliders.
Choose Run B using Compare above, or choose a reference in Lap settings and
-benchmark. In 3D, both tracks remain in the same scene, laid out side by side.
+benchmark, then enable Shared space in 3D. Both tracks remain in the same scene,
+laid out side by side. With Shared space off, the original single-lap view remains.
Click a track to bring the camera toward it. Scroll back out, choose Show
both laps or press Escape to frame both again. Rotation, pan and axis
controls affect the shared camera. PNG saves the current camera view.
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index 89ea057..d9f1c66 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -81,8 +81,7 @@
-
-
+
@@ -95,24 +94,27 @@
-
-
-
+
-
-
+
+
+
+
+
-
-
+
+
-
+
-
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index 209b3e7..e3edaaf 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -96,6 +96,6 @@ internal BitmapSource CaptureMapImage()
public sealed class LapReviewMapHeightConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
- value is double height && double.IsFinite(height) && height > 0 ? Math.Clamp(height - 180, 128, 360) : 360d;
+ value is double height && double.IsFinite(height) && height > 0 ? Math.Clamp(height - 235, 70, 360) : 360d;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
index 6f7f66a..07ad875 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -15,7 +15,7 @@ public sealed partial class LapReviewViewModel
private LapReviewPlotData? _legendData;
private LapContactChoice? _selectedContact;
private string _mapStatus = "";
- public bool Is3D { get => _is3D; set { if (Set(ref _is3D, value)) { Changed(nameof(Is2D)); Changed(nameof(MapPlotA)); Changed(nameof(ComparisonMapPlot)); Changed(nameof(IsMapComparison)); Changed(nameof(HasMapReference)); } } }
+ public bool Is3D { get => _is3D; set { if (Set(ref _is3D, value)) { Changed(nameof(Is2D)); RefreshMapMode(); } } }
public bool Is2D { get => !Is3D; set { if (value) Is3D = false; } }
public bool ShowContacts { get => _showContacts; set { if (Set(ref _showContacts, value)) Changed(nameof(Plot)); } }
public string MapStatus { get => _mapStatus; set => Set(ref _mapStatus, value); }
@@ -26,7 +26,7 @@ public sealed partial class LapReviewViewModel
public string MapCursorValue => Lap is { Points.Length: > 0 } lap ?
$"{Channel.Label}: {RunPresentation.Number(LapReviewPlot.Value(lap.Points[Cursor], Plot, Cursor), " " + LapReviewPlot.Unit(Plot))}" : "";
public string ElevationSummary { get; private set; } = "";
- public bool HasMapReference => !Is3D && Plot.HasDistinctReference;
+ public bool HasMapReference => !IsMapComparison && Plot.HasDistinctReference;
public string ContactSummary => _contacts.Length == 0 ? "No contact markers" : $"{_contacts.Length} contact marker{(_contacts.Length == 1 ? "" : "s")}";
public IReadOnlyList ContactChoices { get; private set; } = [];
public LapContactChoice? SelectedContact
@@ -133,15 +133,16 @@ private void RefreshMapDetails()
LegendTitle = Channel.Label + (IsWheelChannel ? " · " + Wheels[SelectedWheel] : "");
LegendMinimum = range.HasValues ? $"{range.Minimum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
LegendMaximum = range.HasValues ? $"{range.Maximum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
- var positions = HasMapComparison && Reference is { } reference
+ var positions = IsMapComparison && Reference is { } reference
? data.Lap!.Points.Concat(reference.Points) : data.Lap?.Points ?? [];
ElevationSummary = data.Lap is { Points.Length: > 0 } ?
- $"{(HasMapComparison ? "Both laps’ recorded elevation" : "Recorded elevation")}: {positions.Min(p => p.Position.Y):0.0}–{positions.Max(p => p.Position.Y):0.0} m · true scale" : "";
+ $"{(IsMapComparison ? "Both laps’ recorded elevation" : "Recorded elevation")}: {positions.Min(p => p.Position.Y):0.0}–{positions.Max(p => p.Position.Y):0.0} m · true scale" : "";
foreach (var name in new[] { nameof(LegendMinimum), nameof(LegendMaximum), nameof(LegendTitle), nameof(ElevationSummary), nameof(MapTitle), nameof(MapATitle), nameof(MapBTitle) }) Changed(name);
}
Changed(nameof(MapCursorValue)); Changed(nameof(MapBCursorValue)); Changed(nameof(MapPlotA)); Changed(nameof(MapPlotB));
Changed(nameof(ComparisonMapPlot));
Changed(nameof(IsMapComparison));
+ Changed(nameof(CanUseSharedSpace));
Changed(nameof(HasMapComparison)); Changed(nameof(MapComparisonHint)); Changed(nameof(HasMapReference)); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
if (MarkContactCommand is RunUiCommand mark) mark.RaiseCanExecuteChanged();
if (RemoveContactCommand is RunUiCommand remove) remove.RaiseCanExecuteChanged();
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
index 8ae95cf..3fd9886 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
@@ -6,13 +6,17 @@ public sealed partial class LapReviewViewModel
{
private LapReviewColorRange? _sharedMapRange;
private LapReviewContact[] _referenceMapContacts = [];
+ private bool _sharedSpace;
public bool HasMapComparison => Plot.HasDistinctReference && _reverseComparison?.CanCompare == true;
- public bool IsMapComparison => Is3D && HasMapComparison;
- public string MapATitle => Lap is { } lap ? $"A · {lap.RunName} · {lap.Label}" : "Run A";
+ public bool CanUseSharedSpace => Is3D && HasMapComparison;
+ public bool SharedSpace { get => _sharedSpace; set { if (Set(ref _sharedSpace, value)) RefreshMapMode(); } }
+ public bool IsMapComparison => CanUseSharedSpace && SharedSpace;
+ public string MapATitle => IsMapComparison ? Lap is { } lap ? $"A · {lap.RunName} · {lap.Label}" : "Run A" : MapTitle;
public string MapBTitle => Reference is { } lap ? $"B · {lap.RunName} · {lap.Label}" : "Run B";
- public string MapComparisonHint => HasMapComparison
+ public string MapComparisonHint => IsMapComparison
? "Both laps use the same color scale. Click a track to focus it; zoom out to see both."
- : "Use Compare above to choose Run B, or choose another reference lap in Lap settings.";
+ : HasMapComparison ? "Enable Shared space to view both laps beside each other."
+ : "Choose Run B using Compare to enable Shared space.";
public string MapBCursorValue
{
get
@@ -25,7 +29,8 @@ public string MapBCursorValue
return $"{Channel.Label}: {RunPresentation.Number(value, " " + LapReviewPlot.Unit(data))}";
}
}
- public LapReviewPlotData MapPlotA => Plot with { ShowReferencePath = !Is3D, ColorRangeOverride = _sharedMapRange };
+ public LapReviewPlotData MapPlotA => IsMapComparison
+ ? Plot with { ShowReferencePath = false, ColorRangeOverride = _sharedMapRange } : Plot;
public LapReviewPlotData? ComparisonMapPlot => IsMapComparison ? MapPlotB : null;
public LapReviewPlotData MapPlotB
{
@@ -59,10 +64,17 @@ public void PickReferencePoint(int index)
Cursor = matched;
}
+ private void RefreshMapMode()
+ {
+ _legendData = null;
+ RefreshMapDetails();
+ Changed(nameof(CanUseSharedSpace));
+ }
+
private LapReviewColorRange SharedMapRange(LapReviewPlotData data)
{
var a = LapReviewColorRange.From(data);
- if (!HasMapComparison) return a;
+ if (!IsMapComparison) return a;
var b = LapReviewColorRange.From(ReferencePlot());
if (!a.HasValues) return b;
if (!b.HasValues) return a;
diff --git a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
index b139bab..1147bb4 100644
--- a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
@@ -11,6 +11,74 @@ namespace Wisp.App.Tests;
public sealed class LapReviewMapComparisonTests
{
+ [Fact]
+ public void SharedSpaceIsOptInAndTogglingRestoresTheOriginalMapAndLegend() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var reference = LapReviewFixtures.Create(reference: true);
+ reference = reference with
+ {
+ Samples = reference.Samples.Select(s => s with { State = s.State with { PowerWatts = s.State.PowerWatts * 2 } }).ToArray()
+ };
+ model.SetRuns(LapReviewFixtures.Create(), reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Channel = model.Channels.Single(c => c.Channel == LapReviewChannel.Power);
+ model.Cursor = 150;
+ Assert.True(model.HasMapComparison);
+ Assert.False(model.SharedSpace);
+ Assert.False(model.CanUseSharedSpace);
+ AssertOriginalMap();
+ model.Is3D = true;
+ Assert.True(model.CanUseSharedSpace);
+ Assert.False(model.SharedSpace);
+ AssertOriginalMap();
+ var original = model.Plot;
+ var minimum = model.LegendMinimum; var maximum = model.LegendMaximum;
+ var elevation = model.ElevationSummary;
+ var metrics = model.Metrics[0];
+
+ model.SharedSpace = true;
+ Assert.True(model.IsMapComparison);
+ Assert.NotNull(model.ComparisonMapPlot);
+ Assert.False(model.MapPlotA.ShowReferencePath);
+ Assert.False(model.HasMapReference);
+ Assert.True(model.MapPlotA.ColorRangeOverride!.Value.Maximum > LapReviewColorRange.From(original).Maximum);
+ Assert.NotEqual(maximum, model.LegendMaximum);
+ Assert.StartsWith("Both laps", model.ElevationSummary);
+ Assert.Equal(original, model.Plot);
+ Assert.Same(metrics, model.Metrics[0]);
+
+ model.Is3D = false;
+ Assert.False(model.CanUseSharedSpace);
+ AssertOriginalMap();
+ Assert.Equal(minimum, model.LegendMinimum); Assert.Equal(maximum, model.LegendMaximum);
+ Assert.Equal(elevation, model.ElevationSummary);
+
+ model.Is3D = true;
+ model.SharedSpace = true;
+ Assert.True(model.IsMapComparison);
+ model.SharedSpace = false;
+ AssertOriginalMap();
+ Assert.Equal(original, model.MapPlotA);
+ Assert.Equal(minimum, model.LegendMinimum); Assert.Equal(maximum, model.LegendMaximum);
+ Assert.Equal(elevation, model.ElevationSummary);
+ Assert.Same(metrics, model.Metrics[0]);
+
+ void AssertOriginalMap()
+ {
+ Assert.False(model.IsMapComparison);
+ Assert.Null(model.ComparisonMapPlot);
+ Assert.Equal(model.Plot, model.MapPlotA);
+ Assert.Null(model.MapPlotA.ColorRangeOverride);
+ Assert.True(model.MapPlotA.ShowReferencePath);
+ Assert.True(model.HasMapReference);
+ var range = LapReviewColorRange.From(model.Plot);
+ Assert.Equal($"{range.Minimum:0.##} {LapReviewPlot.Unit(model.Plot)}", model.LegendMinimum);
+ Assert.Equal($"{range.Maximum:0.##} {LapReviewPlot.Unit(model.Plot)}", model.LegendMaximum);
+ }
+ });
+
[Fact]
public void DistinctComparableLapsUseOneScaleForBothMapsAndTheLegend() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
{
@@ -24,7 +92,9 @@ public void DistinctComparableLapsUseOneScaleForBothMapsAndTheLegend() => LapRev
model.SetRuns(LapReviewFixtures.Create(), reference);
await LapReviewComparisonTestSupport.Ready(model);
model.Is3D = true;
+ model.SharedSpace = true;
Assert.True(model.HasMapComparison);
+ Assert.True(model.IsMapComparison);
Assert.False(model.MapPlotA.ShowReferencePath);
Assert.False(model.MapPlotB.ShowReferencePath);
foreach (var channel in new[] { LapReviewChannel.Speed, LapReviewChannel.Power, LapReviewChannel.Brake })
@@ -54,6 +124,7 @@ public void ReferenceCursorAndSectionUseSpatialMatchesAndPickingBMovesTheSharedC
var model = fixture.Model;
model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
model.Cursor = 150;
Assert.Equal(model.Plot.Comparison!.Points[150].ReferencePointIndex, model.MapPlotB.Cursor);
Assert.InRange(model.MapPlotB.Cursor, 0, model.Reference!.Points.Length - 1);
@@ -101,6 +172,7 @@ public void ReferenceCursorInterpolatesBetweenOriginalSamplesInsteadOfSnappingTo
};
model.SetRuns(LapReviewFixtures.Create(), reference);
await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
Assert.True(model.HasMapComparison);
model.Cursor = 150;
var match = model.Plot.Comparison!.Points[model.Cursor];
@@ -123,6 +195,7 @@ public void UnmatchedSectionEndpointHidesReferenceSelectionAndUnmatchedCursor()
var model = fixture.Model;
model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
model.Cursor = 75;
model.SectionStartCommand.Execute(null);
await LapReviewComparisonTestSupport.Ready(model);
@@ -150,6 +223,7 @@ public void EachMapKeepsItsOwnContactsAndTheVisibilityToggleAppliesToBoth() => L
model.SetRuns(LapReviewFixtures.Create() with { Markers = [new(10, "Contact")] },
LapReviewFixtures.Create(reference: true) with { Markers = [new(15, "Contact")] });
await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
Assert.True(model.HasMapComparison);
var first = Assert.Single(model.MapPlotA.Contacts!, c => c.Kind == LapReviewContactKind.UserMarkedContact);
var second = Assert.Single(model.MapPlotB.Contacts!, c => c.Kind == LapReviewContactKind.UserMarkedContact);
@@ -170,14 +244,23 @@ public void SelfReferenceMissingReferenceAndClearDoNotCreateASecondMap() => LapR
model.SetRuns(LapReviewFixtures.Create(), null);
await LapReviewComparisonTestSupport.Ready(model);
Assert.True(model.HasLap); Assert.NotNull(model.Reference);
+ model.Is3D = true; model.SharedSpace = true;
Assert.False(model.HasMapComparison);
+ Assert.False(model.CanUseSharedSpace); Assert.False(model.IsMapComparison);
+ Assert.Null(model.ComparisonMapPlot);
model.Reference = null;
await LapReviewComparisonTestSupport.Ready(model);
+ model.SharedSpace = true;
Assert.False(model.HasMapComparison); Assert.Null(model.MapPlotB.Lap);
+ Assert.False(model.CanUseSharedSpace); Assert.False(model.IsMapComparison);
+ Assert.Null(model.ComparisonMapPlot);
Assert.Equal("", model.MapBCursorValue);
model.SetRuns(null, null);
await LapReviewComparisonTestSupport.Ready(model);
+ model.SharedSpace = true;
Assert.False(model.HasLap); Assert.False(model.HasMapComparison);
+ Assert.False(model.CanUseSharedSpace); Assert.False(model.IsMapComparison);
+ Assert.Null(model.ComparisonMapPlot);
Assert.Null(model.MapPlotA.Lap); Assert.Null(model.MapPlotB.Lap);
});
@@ -196,7 +279,10 @@ public void IncompatibleRoutesDoNotOfferMatchedReferenceNavigation() => LapRevie
};
fixture.Model.SetRuns(LapReviewFixtures.Create(), reference);
await LapReviewComparisonTestSupport.Ready(fixture.Model);
+ fixture.Model.Is3D = true; fixture.Model.SharedSpace = true;
Assert.False(fixture.Model.HasMapComparison);
+ Assert.False(fixture.Model.CanUseSharedSpace); Assert.False(fixture.Model.IsMapComparison);
+ Assert.Null(fixture.Model.ComparisonMapPlot);
fixture.Model.Cursor = 120;
fixture.Model.PickReferencePoint(220);
Assert.Equal(120, fixture.Model.Cursor);
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index 710296e..9504f86 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -50,6 +50,7 @@ internal static void Run(string unusedStoreDirectory, Action check
var mapViewport = (FrameworkElement)view.FindName("MapViewport");
var scrubBar = (FrameworkElement)view.FindName("ScrubBar");
var showBoth = (Button)view.FindName("ShowBothMaps");
+ var sharedSpace = (CheckBox)view.FindName("SharedSpaceToggle");
var resetCamera = (Button)view.FindName("ResetCamera");
var cameraControls = (FrameworkElement)view.FindName("CameraControls");
var trace = (LapReviewPlot)view.FindName("Trace");
@@ -77,6 +78,7 @@ internal static void Run(string unusedStoreDirectory, Action check
check(review.PinCommand.CanExecute(null) && !review.UnpinCommand.CanExecute(null), "benchmark-command-availability");
check(!review.Is3D && view2D.IsChecked == true && track.Visibility == Visibility.Visible &&
track3D.Visibility == Visibility.Collapsed, "existing-2d-default-preserved");
+ check(!review.SharedSpace && sharedSpace.IsChecked == false, "shared-space-is-opt-in");
check(review.Lap!.Points.Max(point => point.Position.Y) - review.Lap.Points.Min(point => point.Position.Y) > 30,
"elevated-recording-fixture");
check(new[] { LapReviewChannel.Speed, LapReviewChannel.Delta, LapReviewChannel.Throttle, LapReviewChannel.Brake,
@@ -144,6 +146,20 @@ internal static void Run(string unusedStoreDirectory, Action check
check(review.Is3D && view3D.IsChecked == true && track.Visibility == Visibility.Collapsed &&
track3D.Visibility == Visibility.Visible, name + "/3d-mode-binding");
check(track3D.IsReady && track3D.PreparedSegmentCount > 0, name + "/3d-scene-ready");
+ check(!review.SharedSpace && !review.IsMapComparison && !track3D.IsComparison && track3D.ComparisonData is null &&
+ track3D.Data is { ShowReferencePath: true, HasDistinctReference: true } originalMap &&
+ ReferenceEquals(originalMap.Reference, selectedReference) && showBoth.Visibility == Visibility.Collapsed,
+ name + "/normal-3d-preserves-original-map-and-reference");
+ check(sharedSpace.IsEnabled && sharedSpace.Visibility == Visibility.Visible && review.CanUseSharedSpace,
+ name + "/shared-space-toggle-available-for-comparison");
+ CheckMapAndScrubber(); Capture("map-3d-normal");
+ VerifyMapImage("3d-normal", track3D);
+ var normalData = track3D.Data!;
+ var normalRange = LapReviewColorRange.From(normalData);
+ sharedSpace.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump(); Arrange(surface, size);
+ Await(track3D.PrepareForTestAsync()); Pump();
+ check(review.SharedSpace && review.IsMapComparison && sharedSpace.IsChecked == true,
+ name + "/shared-space-toggle-activates-comparison");
check(review.HasMapComparison && track3D.IsComparison && track3D.PreparedReferenceSegmentCount > 0 && track3D.FocusedLap == 0,
name + "/3d-comparison-opens-both-tracks-in-one-scene");
CheckMapAndScrubber();
@@ -237,6 +253,17 @@ internal static void Run(string unusedStoreDirectory, Action check
track3D.Roll == camera.Roll && track3D.ZoomFactor == camera.ZoomFactor,
name + "/reset-restores-whole-scene-camera");
CheckMapAndScrubber(); Capture("map-3d-comparison-overview");
+ var cursorBeforeNormal = review.Cursor;
+ sharedSpace.SetCurrentValue(ToggleButton.IsCheckedProperty, false); Pump(); Arrange(surface, size);
+ Await(track3D.PrepareForTestAsync()); Pump();
+ check(!review.SharedSpace && !review.IsMapComparison && !track3D.IsComparison &&
+ track3D.ComparisonData is null && track3D.Data is { ShowReferencePath: true, HasDistinctReference: true } restoredMap &&
+ ReferenceEquals(restoredMap.Lap, selectedLap) && ReferenceEquals(restoredMap.Reference, selectedReference) &&
+ LapReviewColorRange.From(restoredMap) == normalRange &&
+ review.Cursor == cursorBeforeNormal && review.Plot.SectionStart == 125 && review.Plot.SectionEnd == 265,
+ name + "/shared-space-off-restores-original-3d-with-state");
+ CheckMapAndScrubber(); Capture("map-3d-normal-restored");
+ VerifyMapImage("3d-normal-restored", track3D);
view2D.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump(); Arrange(surface, size);
check(!review.Is3D && track.Visibility == Visibility.Visible && track3D.Visibility == Visibility.Collapsed &&
track.Data?.Cursor == review.Cursor &&
@@ -264,7 +291,7 @@ void ScrollTo(FrameworkElement element)
{
var bounds = track.TransformToAncestor(scroll).TransformBounds(new Rect(track.RenderSize));
check(bounds.Top >= -.5 && bounds.Bottom <= scroll.ViewportHeight + .5, name + "/whole-map-fits-aligned-viewport");
- check(mapViewport.ActualHeight is >= 100 and <= 360 && track.ActualHeight >= 60 &&
+ check(mapViewport.ActualHeight is >= 60 and <= 360 && track.ActualHeight >= 60 &&
track.ActualHeight <= mapViewport.ActualHeight, name + "/responsive-map-keeps-renderable-height");
}
}
From 061e3dfb84e95365868b6d64f9f6ee3552756eab Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 20:47:45 -0500
Subject: [PATCH 06/15] Declare lap camera binding sources and cover
shared-space controls
---
src/Wisp.App/Runs/LapReviewView.Workspace.cs | 1 -
src/Wisp.App/Runs/LapReviewView.xaml | 6 +++---
tests/Wisp.App.Tests/CalmSidebarTests.cs | 8 +++++++-
tools/Wisp.UiReview/LapReviewUiChecks.cs | 16 ++++++++++++++--
4 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/src/Wisp.App/Runs/LapReviewView.Workspace.cs b/src/Wisp.App/Runs/LapReviewView.Workspace.cs
index a8b473f..68747b3 100644
--- a/src/Wisp.App/Runs/LapReviewView.Workspace.cs
+++ b/src/Wisp.App/Runs/LapReviewView.Workspace.cs
@@ -7,7 +7,6 @@ public partial class LapReviewView
{
private void InitializeMapWorkspace()
{
- CameraControls.DataContext = Track3D;
Track3D.ReferencePointChosen += index =>
{
if (DataContext is LapReviewViewModel model) model.PickReferencePoint(index);
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index d9f1c66..71c16d5 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -135,9 +135,9 @@
-
-
-
+
+
+
diff --git a/tests/Wisp.App.Tests/CalmSidebarTests.cs b/tests/Wisp.App.Tests/CalmSidebarTests.cs
index 93176c8..c3b283c 100644
--- a/tests/Wisp.App.Tests/CalmSidebarTests.cs
+++ b/tests/Wisp.App.Tests/CalmSidebarTests.cs
@@ -137,11 +137,17 @@ internal static void AssertOnCurrentDispatcher(MainWindow window, FrameworkEleme
Assert.Same(window.FindResource("CardStyle"), runLibrary.Style.BasedOn);
var runToggles = LogicalDescendants(runs).OfType().ToArray();
Assert.Equal(
- ["AutomaticRecording", "FullThrottleOnly", "HotkeyEnabled", "MarkerHotkeyEnabled", "SameSpeed", "ShowContacts"],
+ ["AutomaticRecording", "FullThrottleOnly", "HotkeyEnabled", "MarkerHotkeyEnabled", "SameSpeed", "SharedSpace", "ShowContacts"],
runToggles.Select(runToggle => Assert.IsType(
BindingOperations.GetBinding(runToggle, ToggleButton.IsCheckedProperty)).Path.Path)
.OrderBy(path => path, StringComparer.Ordinal));
Assert.All(runToggles, runToggle => Assert.Same(window.FindResource("ToggleSwitchStyle"), runToggle.Style));
+ var sharedSpace = Assert.Single(runToggles, runToggle => runToggle.Name == "SharedSpaceToggle");
+ Assert.Equal("Shared space", sharedSpace.Content);
+ Assert.Equal("SharedSpace", Assert.IsType(
+ BindingOperations.GetBinding(sharedSpace, ToggleButton.IsCheckedProperty)).Path.Path);
+ Assert.Equal("CanUseSharedSpace", Assert.IsType(
+ BindingOperations.GetBinding(sharedSpace, UIElement.IsEnabledProperty)).Path.Path);
foreach (var name in new[] { "ShowGraphsButton", "RunRecordButton" })
Assert.Same(window.FindResource("PrimaryButtonStyle"), Assert.IsType(runs.FindName(name)).Style);
var binding = Assert.IsType(BindingOperations.GetBinding(navigation, Selector.SelectedIndexProperty));
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index 9504f86..17f594a 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -5,6 +5,7 @@
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
+using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -218,7 +219,7 @@ internal static void Run(string unusedStoreDirectory, Action check
track3D.FocusLap(1); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
check(track3D.FocusedLap == 1 && track3D.RenderSize == overviewSize && showBoth.Visibility == Visibility.Visible &&
ProjectedTrackBounds(reference: false).Width > overviewA.Width * 1.15 &&
- ReferenceEquals(cameraControls.DataContext, track3D), name + "/focus-A-moves-shared-camera");
+ CameraBindingsMatch(), name + "/focus-A-moves-shared-camera");
CheckBothGeometriesRetained();
ScrollTo(scrubBar); Capture("map-3d-focus-A");
VerifyMapImage("3d-focus-A", track3D);
@@ -230,7 +231,7 @@ internal static void Run(string unusedStoreDirectory, Action check
track3D.FocusLap(2); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
check(track3D.FocusedLap == 2 && track3D.RenderSize == overviewSize &&
ProjectedTrackBounds(reference: true).Width > overviewB.Width * 1.15 &&
- ReferenceEquals(cameraControls.DataContext, track3D), name + "/focus-B-moves-shared-camera");
+ CameraBindingsMatch(), name + "/focus-B-moves-shared-camera");
CheckBothGeometriesRetained();
var referenceCursor = track3D.ComparisonData!.Cursor;
var nextReference = Math.Clamp(referenceCursor + 1, 0, review.Reference!.Points.Length - 1);
@@ -314,6 +315,17 @@ void CheckSharedColorScale()
check(a is not null && b is not null && a.ColorRangeOverride is { } shared && b.ColorRangeOverride == shared &&
LapReviewColorRange.From(a) == LapReviewColorRange.From(b), name + "/A-B-share-value-color-range-" + review.Channel.Channel);
}
+ bool CameraBindingsMatch()
+ {
+ var controls = Descendants(cameraControls).OfType().ToArray();
+ return new[] { (Name: "3D camera yaw", Property: nameof(LapReviewTrack3D.Yaw), Value: track3D.Yaw),
+ (Name: "3D camera pitch", Property: nameof(LapReviewTrack3D.Pitch), Value: track3D.Pitch),
+ (Name: "3D camera roll", Property: nameof(LapReviewTrack3D.Roll), Value: track3D.Roll) }
+ .All(axis => controls.SingleOrDefault(control => AutomationProperties.GetName(control) == axis.Name) is { } slider &&
+ slider.GetBindingExpression(RangeBase.ValueProperty) is { } binding &&
+ ReferenceEquals(binding.DataItem, track3D) && binding.ParentBinding.Path?.Path == axis.Property &&
+ binding.ParentBinding.Mode == BindingMode.TwoWay && Math.Abs(slider.Value - axis.Value) < 1e-9);
+ }
Rect ProjectedTrackBounds(bool reference)
{
var points = (reference ? track3D.ComparisonData : track3D.Data)!.Lap!.Points;
From 09254a2207d0fc193d0cc6f8b41ff477e744395a Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 21:31:07 -0500
Subject: [PATCH 07/15] Fix shared lap comparison layout and add private setup
skip
---
installer/PrivateShiftCaptureGuide.txt | 15 +-
src/Wisp.App/AppSettings.cs | 17 +-
src/Wisp.App/PrivateSetupPolicy.cs | 11 +
src/Wisp.App/Runs/LapReviewPlot.cs | 3 +
.../Runs/LapReviewTrack3D.Comparison.cs | 2 +-
src/Wisp.App/Runs/LapReviewTrack3D.cs | 8 +-
src/Wisp.App/Runs/LapReviewView.Workspace.cs | 23 ++
src/Wisp.App/Runs/LapReviewView.xaml | 24 +-
src/Wisp.App/Runs/LapReviewView.xaml.cs | 14 +-
src/Wisp.App/Runs/LapReviewViewModel.Map.cs | 3 +-
.../Runs/LapReviewViewModel.MapComparison.cs | 31 ++-
src/Wisp.App/Runs/LapReviewViewModel.cs | 18 +-
src/Wisp.App/Runs/RunsPage.xaml | 66 +++--
src/Wisp.App/Runs/RunsPage.xaml.cs | 39 ++-
src/Wisp.App/SetupWindow.xaml | 4 +
src/Wisp.App/SetupWindow.xaml.cs | 9 +
.../LapReviewMapComparisonTests.cs | 115 ++++++++-
tests/Wisp.App.Tests/LapReviewTrack3DTests.cs | 43 ++++
tests/Wisp.App.Tests/PrivateSetupSkipTests.cs | 242 ++++++++++++++++++
tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs | 1 +
.../LapRecordedComparisonReview.cs | 233 +++++++++++++++++
tools/Wisp.UiReview/LapReviewUiChecks.cs | 94 ++++++-
tools/Wisp.UiReview/Program.cs | 6 +
tools/Wisp.UiReview/RunsReview.cs | 5 +-
24 files changed, 959 insertions(+), 67 deletions(-)
create mode 100644 src/Wisp.App/PrivateSetupPolicy.cs
create mode 100644 tests/Wisp.App.Tests/PrivateSetupSkipTests.cs
create mode 100644 tools/Wisp.UiReview/LapRecordedComparisonReview.cs
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index 2d3cb86..d742902 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -1,10 +1,14 @@
Wisp 2.6.4 Lap review 3D test
-Private build ID: lap-review-3d-20261006-02
+Private build ID: lap-review-3d-20261006-03
This is a private test build, not a public release. It updates your existing
Wisp installation and keeps your settings, profiles, calibrations, runs and
clips. It is not a separate installation.
+Choose Skip setup to test this private build without opening Forza. The skip
+lasts for the current session and does not change your saved setup evidence.
+Public releases keep the normal setup flow.
+
Changes
- Switch between the existing 2D lap map and a 3D view of recorded elevation.
- Rotate, tilt, roll, pan and zoom the 3D map; reset the camera at any time.
@@ -17,10 +21,17 @@ Changes
color scale. Click a track to move closer; zoom back out to see both.
- Keep the lap-position slider above the map and batch drag updates to one
per UI render cycle, preserving the final position when released.
+- Use one page scrollbar for the comparison controls and lap review.
+- Compare recorded tracks from different cars in Shared space. Timing and
+ matched-section checks remain separate; incompatible laps use independent cursors.
+- Give the track more room with a taller automatic view and a draggable lower edge.
Controls
Drag: rotate. Shift-drag or right-drag: pan. Ctrl-drag: roll.
-Scroll: zoom. Double-click or Reset camera: restore the workspace camera.
+Click the map before using scroll to zoom; otherwise the wheel scrolls the page.
+Double-click or Reset camera: restore the workspace camera.
+Drag the grip below the map to resize it. Focus the grip and use Up/Down to
+adjust the height, or Home to restore automatic sizing.
Click the line: select a point. Arrow keys: move along the lap.
Home/End: first/last point. Camera controls also provide axis sliders.
Choose Run B using Compare above, or choose a reference in Lap settings and
diff --git a/src/Wisp.App/AppSettings.cs b/src/Wisp.App/AppSettings.cs
index 4c09cf4..87e451f 100644
--- a/src/Wisp.App/AppSettings.cs
+++ b/src/Wisp.App/AppSettings.cs
@@ -207,7 +207,20 @@ public double TorqueGaugeScale
internal bool InstallerSetupRequired { get; private set; }
[JsonIgnore]
- public bool RequiresSetup => InstallerSetupRequired || SetupCompletion?.IsValid != true;
+ internal bool PrivateSetupSkippedForSession { get; private set; }
+
+ private bool VerifiedSetupRequired => InstallerSetupRequired || SetupCompletion?.IsValid != true;
+
+ [JsonIgnore]
+ public bool RequiresSetup => PrivateSetupPolicy.RequiresSetup(
+ VerifiedSetupRequired, PrivateSetupSkippedForSession, ApplicationVersionInfo.DiagnosticBuildId);
+
+ internal bool TrySkipSetupForPrivateSession()
+ {
+ if (!PrivateSetupPolicy.IsAvailable) return false;
+ PrivateSetupSkippedForSession = true;
+ return true;
+ }
public Dictionary Placements { get; set; } = new();
public Dictionary GForcePlacements { get; set; } = new();
@@ -442,7 +455,7 @@ DebugLoggingExpiresAtUtc is not { } debugLoggingExpiry ||
}
// Older releases set this flag on the first packet. Only an explicit,
// versioned wizard completion can now satisfy the startup gate.
- HasCompletedSetup = !RequiresSetup;
+ HasCompletedSetup = !VerifiedSetupRequired;
Calibrations = Calibrations
.Where(snapshot =>
snapshot is not null &&
diff --git a/src/Wisp.App/PrivateSetupPolicy.cs b/src/Wisp.App/PrivateSetupPolicy.cs
new file mode 100644
index 0000000..954ef56
--- /dev/null
+++ b/src/Wisp.App/PrivateSetupPolicy.cs
@@ -0,0 +1,11 @@
+namespace Wisp.App;
+
+internal static class PrivateSetupPolicy
+{
+ internal static bool IsAvailable => CanSkip(ApplicationVersionInfo.DiagnosticBuildId);
+
+ internal static bool CanSkip(string? diagnosticBuildId) => !string.IsNullOrWhiteSpace(diagnosticBuildId);
+
+ internal static bool RequiresSetup(bool verifiedSetupRequired, bool skippedForSession, string? diagnosticBuildId) =>
+ verifiedSetupRequired && !(skippedForSession && CanSkip(diagnosticBuildId));
+}
diff --git a/src/Wisp.App/Runs/LapReviewPlot.cs b/src/Wisp.App/Runs/LapReviewPlot.cs
index 0e0bb55..f9d2f93 100644
--- a/src/Wisp.App/Runs/LapReviewPlot.cs
+++ b/src/Wisp.App/Runs/LapReviewPlot.cs
@@ -15,6 +15,9 @@ public sealed record LapReviewPlotData(LapReviewLap? Lap, LapReviewLap? Referenc
public bool ShowReferencePath { get; init; } = true;
internal LapReviewColorRange? ColorRangeOverride { get; init; }
internal LapPosition? CursorPositionOverride { get; init; }
+ public bool HasDistinctMapReference => Lap is { Points.Length: > 1 } lap && Reference is { Points.Length: > 1 } reference &&
+ (lap.RunId != reference.RunId || lap.TimingMode != reference.TimingMode || lap.Number != reference.Number ||
+ lap.Points[0].SampleIndex != reference.Points[0].SampleIndex);
public bool HasDistinctReference => Comparison?.CanCompare == true &&
Lap is { Points.Length: > 0 } lap && Reference is { Points.Length: > 0 } reference &&
(lap.RunId != reference.RunId || lap.TimingMode != reference.TimingMode || lap.Number != reference.Number ||
diff --git a/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs b/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs
index 719c45c..a3b32d3 100644
--- a/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs
+++ b/src/Wisp.App/Runs/LapReviewTrack3D.Comparison.cs
@@ -29,7 +29,7 @@ public sealed partial class LapReviewTrack3D
private long _cameraMotionStarted;
private Point3D _motionStartTarget, _motionEndTarget;
private double _motionStartZoom, _motionEndZoom;
- private LapReviewPlotData? EffectiveComparison => Data?.HasDistinctReference == true && ComparisonData is { Lap.Points.Length: > 1 } data ? data : null;
+ private LapReviewPlotData? EffectiveComparison => Data?.HasDistinctMapReference == true && ComparisonData is { Lap.Points.Length: > 1 } data ? data : null;
internal int PreparedReferenceSegmentCount => _comparisonScene?.SegmentCount ?? 0;
internal bool HasBothPreparedModels => _path.Content is not null && _comparisonPath.Content is not null;
internal bool IsCameraMotionActive => _cameraMotionActive;
diff --git a/src/Wisp.App/Runs/LapReviewTrack3D.cs b/src/Wisp.App/Runs/LapReviewTrack3D.cs
index 540da3c..3567745 100644
--- a/src/Wisp.App/Runs/LapReviewTrack3D.cs
+++ b/src/Wisp.App/Runs/LapReviewTrack3D.cs
@@ -342,7 +342,13 @@ protected override void OnMouseUp(MouseButtonEventArgs e)
}
ReleaseMouseCapture(); e.Handled = true;
}
- protected override void OnMouseWheel(MouseWheelEventArgs e) { base.OnMouseWheel(e); ZoomBy(Math.Pow(1.15, e.Delta / 120d)); e.Handled = true; }
+ protected override void OnMouseWheel(MouseWheelEventArgs e)
+ {
+ base.OnMouseWheel(e);
+ if (!IsKeyboardFocusWithin) return;
+ ZoomBy(Math.Pow(1.15, e.Delta / 120d));
+ e.Handled = true;
+ }
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
diff --git a/src/Wisp.App/Runs/LapReviewView.Workspace.cs b/src/Wisp.App/Runs/LapReviewView.Workspace.cs
index 68747b3..64e4153 100644
--- a/src/Wisp.App/Runs/LapReviewView.Workspace.cs
+++ b/src/Wisp.App/Runs/LapReviewView.Workspace.cs
@@ -1,10 +1,33 @@
using System.Windows;
+using System.Windows.Controls.Primitives;
using System.Windows.Input;
namespace Wisp.App.Runs;
public partial class LapReviewView
{
+ public static readonly DependencyProperty RequestedMapHeightProperty = DependencyProperty.Register(
+ nameof(RequestedMapHeight), typeof(double), typeof(LapReviewView), new PropertyMetadata(double.NaN));
+ public double RequestedMapHeight
+ {
+ get => (double)GetValue(RequestedMapHeightProperty);
+ set => SetValue(RequestedMapHeightProperty, value);
+ }
+
+ private void MapResizeGrip_DragDelta(object sender, DragDeltaEventArgs e) => ResizeMapBy(e.VerticalChange);
+ internal void ResizeMapBy(double change)
+ {
+ if (double.IsFinite(change)) RequestedMapHeight = Math.Clamp(
+ (double.IsFinite(RequestedMapHeight) ? RequestedMapHeight : MapViewport.ActualHeight) + change, 180, 1400);
+ }
+ private void MapResizeGrip_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key is Key.Up or Key.Down) ResizeMapBy(e.Key == Key.Up ? -40 : 40);
+ else if (e.Key == Key.Home) RequestedMapHeight = double.NaN;
+ else return;
+ e.Handled = true;
+ }
+
private void InitializeMapWorkspace()
{
Track3D.ReferencePointChosen += index =>
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index 71c16d5..108d289 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -1,4 +1,4 @@
-
-
+
+
@@ -129,9 +130,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index e3edaaf..1838c34 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -93,9 +93,15 @@ internal BitmapSource CaptureMapImage()
}
}
-public sealed class LapReviewMapHeightConverter : IValueConverter
+public sealed class LapReviewMapHeightConverter : IMultiValueConverter
{
- public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
- value is double height && double.IsFinite(height) && height > 0 ? Math.Clamp(height - 235, 70, 360) : 360d;
- public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length > 1 && values[1] is double requested && double.IsFinite(requested))
+ return Math.Clamp(requested, 180, 1400);
+ return values.Length > 0 && values[0] is double height && double.IsFinite(height) && height > 0
+ ? Math.Clamp(height - 235, 70, 640) : 480d;
+ }
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) =>
+ targetTypes.Select(_ => Binding.DoNothing).ToArray();
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
index 07ad875..aef1e29 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -123,7 +123,8 @@ private void RefreshMapDetails()
{
var data = Plot;
EnsureContacts();
- if (_legendData is not { } old || !ReferenceEquals(old.Lap, data.Lap) || !ReferenceEquals(old.Comparison, data.Comparison) ||
+ if (_legendData is not { } old || !ReferenceEquals(old.Lap, data.Lap) || !ReferenceEquals(old.Reference, data.Reference) ||
+ !ReferenceEquals(old.Comparison, data.Comparison) ||
old.Channel != data.Channel || old.Wheel != data.Wheel || old.SpeedUnit != data.SpeedUnit ||
old.TemperatureUnit != data.TemperatureUnit || old.TorqueUnit != data.TorqueUnit)
{
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
index 3fd9886..a40bd26 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
@@ -7,15 +7,19 @@ public sealed partial class LapReviewViewModel
private LapReviewColorRange? _sharedMapRange;
private LapReviewContact[] _referenceMapContacts = [];
private bool _sharedSpace;
- public bool HasMapComparison => Plot.HasDistinctReference && _reverseComparison?.CanCompare == true;
+ private int _referenceCursor;
+ public bool HasMapComparison => Plot.HasDistinctMapReference;
+ private bool HasMatchedMapComparison => Plot.HasDistinctReference && _reverseComparison?.CanCompare == true;
public bool CanUseSharedSpace => Is3D && HasMapComparison;
public bool SharedSpace { get => _sharedSpace; set { if (Set(ref _sharedSpace, value)) RefreshMapMode(); } }
public bool IsMapComparison => CanUseSharedSpace && SharedSpace;
public string MapATitle => IsMapComparison ? Lap is { } lap ? $"A · {lap.RunName} · {lap.Label}" : "Run A" : MapTitle;
public string MapBTitle => Reference is { } lap ? $"B · {lap.RunName} · {lap.Label}" : "Run B";
public string MapComparisonHint => IsMapComparison
- ? "Both laps use the same color scale. Click a track to focus it; zoom out to see both."
+ ? HasMatchedMapComparison ? "Both laps use the same color scale. Click a track to focus it; zoom out to see both."
+ : "Both laps use the same color scale. Cursors are independent; matched sections and time delta are unavailable."
: HasMapComparison ? "Enable Shared space to view both laps beside each other."
+ : _comparisonRun is not null ? "Run B has no second lap with recorded positions. Choose another run or lap."
: "Choose Run B using Compare to enable Shared space.";
public string MapBCursorValue
{
@@ -23,7 +27,10 @@ public string MapBCursorValue
{
if (!HasMapComparison) return "";
var data = ReferencePlot();
- var value = LapReviewPlot.ReferencePosition(Plot, Cursor) is null ? null :
+ var value = !HasMatchedMapComparison
+ ? data.Lap is { } lap && (uint)data.Cursor < (uint)lap.Points.Length
+ ? LapReviewPlot.Value(lap.Points[data.Cursor], data, data.Cursor) : null
+ : LapReviewPlot.ReferencePosition(Plot, Cursor) is null ? null :
Channel.Channel == LapReviewChannel.Delta
? -_comparison?.Points.ElementAtOrDefault(Cursor)?.DeltaSeconds : LapReviewPlot.ReferenceValue(Plot, Cursor);
return $"{Channel.Label}: {RunPresentation.Number(value, " " + LapReviewPlot.Unit(data))}";
@@ -37,12 +44,12 @@ public LapReviewPlotData MapPlotB
get
{
var data = ReferencePlot();
- var position = LapReviewPlot.ReferencePosition(Plot, Cursor);
+ var position = HasMatchedMapComparison ? LapReviewPlot.ReferencePosition(Plot, Cursor) : null;
return data with
{
ShowReferencePath = false,
ColorRangeOverride = _sharedMapRange,
- Cursor = position.HasValue ? data.Cursor : -1,
+ Cursor = !HasMatchedMapComparison || position.HasValue ? data.Cursor : -1,
CursorPositionOverride = position
};
}
@@ -50,6 +57,10 @@ public LapReviewPlotData MapPlotB
private LapReviewPlotData ReferencePlot()
{
+ if (!HasMatchedMapComparison)
+ return new(Reference, Lap, null, Channel.Channel, _settings.SpeedUnit,
+ Reference is { Points.Length: > 0 } reference ? Math.Clamp(_referenceCursor, 0, reference.Points.Length - 1) : -1,
+ -1, -1, SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, ShowContacts ? _referenceMapContacts : null);
int Match(int index) => _comparison?.Points.ElementAtOrDefault(index)?.ReferencePointIndex ?? -1;
var start = Match(_sectionStart); var end = Match(_sectionEnd);
var matchedSection = start >= 0 && end >= start;
@@ -60,7 +71,15 @@ private LapReviewPlotData ReferencePlot()
public void PickReferencePoint(int index)
{
- if (!HasMapComparison || _reverseComparison!.Points.ElementAtOrDefault(index)?.ReferencePointIndex is not { } matched) return;
+ if (!HasMapComparison || Reference is not { } reference || (uint)index >= (uint)reference.Points.Length) return;
+ if (!HasMatchedMapComparison)
+ {
+ if (_referenceCursor == index) return;
+ _referenceCursor = index;
+ Changed(nameof(MapPlotB)); Changed(nameof(ComparisonMapPlot)); Changed(nameof(MapBCursorValue));
+ return;
+ }
+ if (_reverseComparison!.Points.ElementAtOrDefault(index)?.ReferencePointIndex is not { } matched) return;
Cursor = matched;
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.cs b/src/Wisp.App/Runs/LapReviewViewModel.cs
index 467b7cf..e4622ef 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.cs
@@ -96,7 +96,17 @@ public LapReviewLap? Lap
else RefreshComparison();
}
}
- public LapReviewLap? Reference { get => _reference; set { if (!_disposed && Set(ref _reference, value) && !_applying) RefreshComparison(); } }
+ public LapReviewLap? Reference
+ {
+ get => _reference;
+ set
+ {
+ if (_disposed || !Set(ref _reference, value)) return;
+ _referenceCursor = 0;
+ _referenceMapContacts = [];
+ if (!_applying) RefreshComparison();
+ }
+ }
public string Status { get => _status; private set => Set(ref _status, value); }
public string ReferenceStatus { get => _referenceStatus; private set => Set(ref _referenceStatus, value); }
public string CaptureStatus { get => _captureStatus; set => Set(ref _captureStatus, value); }
@@ -161,7 +171,9 @@ private async Task LoadAsync()
{
Laps.Clear(); foreach (var lap in result.a.Laps) Laps.Add(lap);
ReferenceLaps.Clear(); foreach (var lap in result.b.Laps) ReferenceLaps.Add(lap);
- Reference = ReferenceLaps.Where(UsableBenchmark).MinBy(lap => lap.DurationSeconds);
+ Reference = ReferenceLaps.Where(UsableBenchmark).MinBy(lap => lap.DurationSeconds) ??
+ (comparisonRun is null ? null : ReferenceLaps.LastOrDefault(lap => lap.IsComplete && lap.Points.Length > 1) ??
+ ReferenceLaps.LastOrDefault(lap => lap.Points.Length > 1));
Lap = result.current;
if (Lap is not null && selected is not null && Lap.RunId == selected.RunId &&
Lap.TimingMode == selected.TimingMode && Lap.Points.FirstOrDefault()?.SampleIndex == selected.Points.FirstOrDefault()?.SampleIndex)
@@ -218,7 +230,7 @@ private void ClearReview()
_lap = _reference = null; _comparison = _reverseComparison = null;
_referenceRun = null; _referenceMapContacts = [];
ClearTelemetryContacts();
- _cursor = _sectionStart = _sectionEnd = 0;
+ _cursor = _referenceCursor = _sectionStart = _sectionEnd = 0;
Laps.Clear(); ReferenceLaps.Clear();
_sectionStatistics = _referenceStatistics = null; _referenceSectionSeconds = null;
Metrics.Clear(); Events.Clear(); CursorDetails = null; SectionText = "";
diff --git a/src/Wisp.App/Runs/RunsPage.xaml b/src/Wisp.App/Runs/RunsPage.xaml
index ecaeb08..88e0169 100644
--- a/src/Wisp.App/Runs/RunsPage.xaml
+++ b/src/Wisp.App/Runs/RunsPage.xaml
@@ -341,42 +341,40 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -110,9 +128,13 @@
+
-
-
+
+
+
@@ -127,56 +149,10 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -222,6 +198,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index 1838c34..73d4f99 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -93,14 +93,25 @@ internal BitmapSource CaptureMapImage()
}
}
+public sealed class LapReviewCompactViewportConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
+ value is double height && double.IsFinite(height) && height > 0 && height < 420;
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
+}
+
public sealed class LapReviewMapHeightConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length > 1 && values[1] is double requested && double.IsFinite(requested))
return Math.Clamp(requested, 180, 1400);
+ var overhead = 235d;
+ if (values.Length >= 6 && values[2] is double toolbar && values[3] is double scrub &&
+ values[4] is double readout && values[5] is bool compact)
+ overhead = 110 + toolbar + scrub + (compact ? 0 : readout + 16);
return values.Length > 0 && values[0] is double height && double.IsFinite(height) && height > 0
- ? Math.Clamp(height - 235, 70, 640) : 480d;
+ ? Math.Clamp(height - overhead, 70, 640) : 480d;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) =>
targetTypes.Select(_ => Binding.DoNothing).ToArray();
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
index aef1e29..ab232fe 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -15,7 +15,7 @@ public sealed partial class LapReviewViewModel
private LapReviewPlotData? _legendData;
private LapContactChoice? _selectedContact;
private string _mapStatus = "";
- public bool Is3D { get => _is3D; set { if (Set(ref _is3D, value)) { Changed(nameof(Is2D)); RefreshMapMode(); } } }
+ public bool Is3D { get => _is3D; set { if (_is3D != value) ChangeScrubContext(() => { Set(ref _is3D, value); Changed(nameof(Is2D)); RefreshMapMode(); }); } }
public bool Is2D { get => !Is3D; set { if (value) Is3D = false; } }
public bool ShowContacts { get => _showContacts; set { if (Set(ref _showContacts, value)) Changed(nameof(Plot)); } }
public string MapStatus { get => _mapStatus; set => Set(ref _mapStatus, value); }
@@ -145,6 +145,7 @@ private void RefreshMapDetails()
Changed(nameof(IsMapComparison));
Changed(nameof(CanUseSharedSpace));
Changed(nameof(HasMapComparison)); Changed(nameof(MapComparisonHint)); Changed(nameof(HasMapReference)); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
+ NotifyScrubState();
if (MarkContactCommand is RunUiCommand mark) mark.RaiseCanExecuteChanged();
if (RemoveContactCommand is RunUiCommand remove) remove.RaiseCanExecuteChanged();
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
index a40bd26..8b8aaaf 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
@@ -11,13 +11,13 @@ public sealed partial class LapReviewViewModel
public bool HasMapComparison => Plot.HasDistinctMapReference;
private bool HasMatchedMapComparison => Plot.HasDistinctReference && _reverseComparison?.CanCompare == true;
public bool CanUseSharedSpace => Is3D && HasMapComparison;
- public bool SharedSpace { get => _sharedSpace; set { if (Set(ref _sharedSpace, value)) RefreshMapMode(); } }
+ public bool SharedSpace { get => _sharedSpace; set { if (_sharedSpace != value) ChangeScrubContext(() => { Set(ref _sharedSpace, value); RefreshMapMode(); }); } }
public bool IsMapComparison => CanUseSharedSpace && SharedSpace;
public string MapATitle => IsMapComparison ? Lap is { } lap ? $"A · {lap.RunName} · {lap.Label}" : "Run A" : MapTitle;
public string MapBTitle => Reference is { } lap ? $"B · {lap.RunName} · {lap.Label}" : "Run B";
public string MapComparisonHint => IsMapComparison
? HasMatchedMapComparison ? "Both laps use the same color scale. Click a track to focus it; zoom out to see both."
- : "Both laps use the same color scale. Cursors are independent; matched sections and time delta are unavailable."
+ : "Both laps use the same color scale. Matched sections and time delta are unavailable."
: HasMapComparison ? "Enable Shared space to view both laps beside each other."
: _comparisonRun is not null ? "Run B has no second lap with recorded positions. Choose another run or lap."
: "Choose Run B using Compare to enable Shared space.";
@@ -27,7 +27,7 @@ public string MapBCursorValue
{
if (!HasMapComparison) return "";
var data = ReferencePlot();
- var value = !HasMatchedMapComparison
+ var value = !UsesMatchedReferenceCursor
? data.Lap is { } lap && (uint)data.Cursor < (uint)lap.Points.Length
? LapReviewPlot.Value(lap.Points[data.Cursor], data, data.Cursor) : null
: LapReviewPlot.ReferencePosition(Plot, Cursor) is null ? null :
@@ -44,12 +44,12 @@ public LapReviewPlotData MapPlotB
get
{
var data = ReferencePlot();
- var position = HasMatchedMapComparison ? LapReviewPlot.ReferencePosition(Plot, Cursor) : null;
+ var position = UsesMatchedReferenceCursor ? LapReviewPlot.ReferencePosition(Plot, Cursor) : null;
return data with
{
ShowReferencePath = false,
ColorRangeOverride = _sharedMapRange,
- Cursor = !HasMatchedMapComparison || position.HasValue ? data.Cursor : -1,
+ Cursor = !UsesMatchedReferenceCursor || position.HasValue ? data.Cursor : -1,
CursorPositionOverride = position
};
}
@@ -64,7 +64,8 @@ private LapReviewPlotData ReferencePlot()
int Match(int index) => _comparison?.Points.ElementAtOrDefault(index)?.ReferencePointIndex ?? -1;
var start = Match(_sectionStart); var end = Match(_sectionEnd);
var matchedSection = start >= 0 && end >= start;
- return new(Reference, Lap, _reverseComparison, Channel.Channel, _settings.SpeedUnit, Match(Cursor),
+ return new(Reference, Lap, _reverseComparison, Channel.Channel, _settings.SpeedUnit,
+ UsesMatchedReferenceCursor ? Match(Cursor) : Math.Clamp(_referenceCursor, 0, Math.Max(0, (Reference?.Points.Length ?? 1) - 1)),
matchedSection ? start : -1, matchedSection ? end : -1,
SelectedWheel, _settings.TireTemperatureUnit, _settings.TorqueUnit, ShowContacts ? _referenceMapContacts : null);
}
@@ -72,11 +73,9 @@ private LapReviewPlotData ReferencePlot()
public void PickReferencePoint(int index)
{
if (!HasMapComparison || Reference is not { } reference || (uint)index >= (uint)reference.Points.Length) return;
- if (!HasMatchedMapComparison)
+ if (!UsesMatchedReferenceCursor)
{
- if (_referenceCursor == index) return;
- _referenceCursor = index;
- Changed(nameof(MapPlotB)); Changed(nameof(ComparisonMapPlot)); Changed(nameof(MapBCursorValue));
+ SetReferenceCursor(index);
return;
}
if (_reverseComparison!.Points.ElementAtOrDefault(index)?.ReferencePointIndex is not { } matched) return;
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs b/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
new file mode 100644
index 0000000..90970cf
--- /dev/null
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
@@ -0,0 +1,129 @@
+namespace Wisp.App.Runs;
+
+public sealed partial class LapReviewViewModel
+{
+ private enum ScrubTarget { Both, A, B }
+ private ScrubTarget _scrubTarget;
+ internal event Action? ScrubContextChanging;
+ internal event Action? ScrubContextChanged;
+ public bool ScrubBoth { get => _scrubTarget == ScrubTarget.Both; set { if (value) SetScrubTarget(ScrubTarget.Both); } }
+ public bool ScrubA { get => _scrubTarget == ScrubTarget.A; set { if (value) SetScrubTarget(ScrubTarget.A); } }
+ public bool ScrubB { get => _scrubTarget == ScrubTarget.B; set { if (value) SetScrubTarget(ScrubTarget.B); } }
+ private bool ScrubsReferenceOnly => IsMapComparison && ScrubB;
+ private bool UsesMatchedReferenceCursor => HasMatchedMapComparison && (!IsMapComparison || ScrubBoth);
+ public int MaximumScrubCursor => ScrubsReferenceOnly ? Math.Max(0, (Reference?.Points.Length ?? 1) - 1) : MaximumCursor;
+ public int ScrubCursor
+ {
+ get => ScrubsReferenceOnly ? Math.Clamp(_referenceCursor, 0, MaximumScrubCursor) : Cursor;
+ set
+ {
+ if (_disposed) return;
+ value = Math.Clamp(value, 0, MaximumScrubCursor);
+ if (ScrubsReferenceOnly) { SetReferenceCursor(value); return; }
+ var referenceChanged = false;
+ if (IsMapComparison && ScrubBoth && !HasMatchedMapComparison)
+ {
+ var reference = RelativeReferenceCursor(value);
+ referenceChanged = _referenceCursor != reference;
+ _referenceCursor = reference;
+ }
+ if (Cursor != value) Cursor = value;
+ else if (referenceChanged) NotifyReferenceCursor();
+ }
+ }
+ public string ScrubPosition => !IsMapComparison ? CursorDetails?.Position ?? "" : ScrubA
+ ? "A · " + (CursorDetails?.Position ?? "Unavailable") : ScrubB ? "B · " + ReferenceCursorPosition()
+ : "A · " + (CursorDetails?.Position ?? "Unavailable") + " B · " + ReferenceCursorPosition();
+ public string ScrubHint => !IsMapComparison ? "" : ScrubA ? "Move A only; B stays at its selected position."
+ : ScrubB ? "Move B only; A and the graph stay at their selected position."
+ : HasMatchedMapComparison ? "Move both along matching track positions."
+ : "Move both by relative lap progress (recorded distance); matched timing is unavailable.";
+
+ private void SetScrubTarget(ScrubTarget target)
+ {
+ if (_disposed || _scrubTarget == target) return;
+ ChangeScrubContext(() =>
+ {
+ // Keep the visible B sample when leaving the spatially matched mode.
+ var visibleReference = MapPlotB.Cursor;
+ if (visibleReference >= 0) _referenceCursor = visibleReference;
+ _scrubTarget = target;
+ if (IsMapComparison && target == ScrubTarget.Both && !HasMatchedMapComparison)
+ _referenceCursor = RelativeReferenceCursor(Cursor);
+ Changed(nameof(ScrubBoth)); Changed(nameof(ScrubA)); Changed(nameof(ScrubB));
+ NotifyReferenceCursor();
+ });
+ }
+
+ private void ChangeScrubContext(Action change)
+ {
+ ScrubContextChanging?.Invoke();
+ try { change(); }
+ finally { NotifyScrubState(); ScrubContextChanged?.Invoke(); }
+ }
+
+ private void NotifyScrubState()
+ {
+ Changed(nameof(MaximumScrubCursor)); Changed(nameof(ScrubCursor));
+ Changed(nameof(ScrubPosition)); Changed(nameof(ScrubHint));
+ }
+
+ private void SetReferenceCursor(int value)
+ {
+ if (Reference is not { Points.Length: > 0 } reference) return;
+ value = Math.Clamp(value, 0, reference.Points.Length - 1);
+ if (_referenceCursor == value) return;
+ _referenceCursor = value;
+ NotifyReferenceCursor();
+ }
+
+ private void NotifyReferenceCursor()
+ {
+ Changed(nameof(MapPlotB)); Changed(nameof(ComparisonMapPlot)); Changed(nameof(MapBCursorValue));
+ NotifyScrubState();
+ }
+
+ private int RelativeReferenceCursor(int index)
+ {
+ if (Lap is not { Points.Length: > 1 } lap || Reference is not { Points.Length: > 1 } reference) return 0;
+ index = Math.Clamp(index, 0, lap.Points.Length - 1);
+ if (index == 0) return 0;
+ if (index == lap.Points.Length - 1) return reference.Points.Length - 1;
+ var first = lap.Points[0].DistanceMeters;
+ var span = lap.Points[^1].DistanceMeters - first;
+ if (!double.IsFinite(span) || span <= 0) return 0;
+ var progress = Math.Clamp((lap.Points[index].DistanceMeters - first) / span, 0, 1);
+ // Gaps and a stationary finish can share the same cumulative distance.
+ if (progress >= 1) return reference.Points.Length - 1;
+ var distance = reference.Points[0].DistanceMeters + progress *
+ (reference.Points[^1].DistanceMeters - reference.Points[0].DistanceMeters);
+ // Recorded cumulative distance is monotonic; avoid scanning either lap while dragging.
+ var low = 0; var high = reference.Points.Length - 1;
+ while (low < high)
+ {
+ var middle = low + (high - low) / 2;
+ if (reference.Points[middle].DistanceMeters < distance) low = middle + 1;
+ else high = middle;
+ }
+ return low > 0 && distance - reference.Points[low - 1].DistanceMeters < reference.Points[low].DistanceMeters - distance ? low - 1 : low;
+ }
+
+ private string ReferenceCursorPosition()
+ {
+ var data = MapPlotB;
+ if (data.Lap is not { } lap || (uint)data.Cursor >= (uint)lap.Points.Length) return "Unavailable";
+ var point = lap.Points[data.Cursor];
+ var seconds = point.LapSeconds; var distance = point.DistanceMeters;
+ if (UsesMatchedReferenceCursor && _comparison?.Points.ElementAtOrDefault(Cursor)?.ReferenceLapSeconds is { } matchedSeconds)
+ {
+ seconds = matchedSeconds;
+ if (data.Cursor + 1 < lap.Points.Length)
+ {
+ var next = lap.Points[data.Cursor + 1];
+ if (next.LapSeconds > point.LapSeconds)
+ distance += (next.DistanceMeters - distance) * Math.Clamp((seconds - point.LapSeconds) / (next.LapSeconds - point.LapSeconds), 0, 1);
+ }
+ }
+ return $"{seconds:0.000} s · {distance:0} m";
+ }
+}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.cs b/src/Wisp.App/Runs/LapReviewViewModel.cs
index e4622ef..026ce62 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.cs
@@ -87,13 +87,17 @@ public LapReviewLap? Lap
get => _lap;
set
{
- if (_disposed || !Set(ref _lap, value)) return;
- _cursor = _sectionStart = 0; _sectionEnd = Math.Max(0, MaximumCursor);
- Changed(nameof(MaximumCursor)); Changed(nameof(Cursor)); Changed(nameof(HasLap)); Changed(nameof(CanPin));
- RaiseCommands();
- if (_applying) RefreshCursor();
- else if (_comparisonRun is null && _settings.LapReviewBenchmarkRunId is not null) _ = LoadAsync();
- else RefreshComparison();
+ if (_disposed || Equals(_lap, value)) return;
+ ChangeScrubContext(() =>
+ {
+ Set(ref _lap, value);
+ _cursor = _sectionStart = 0; _sectionEnd = Math.Max(0, MaximumCursor);
+ Changed(nameof(MaximumCursor)); Changed(nameof(Cursor)); Changed(nameof(HasLap)); Changed(nameof(CanPin));
+ RaiseCommands();
+ if (_applying) RefreshCursor();
+ else if (_comparisonRun is null && _settings.LapReviewBenchmarkRunId is not null) _ = LoadAsync();
+ else RefreshComparison();
+ });
}
}
public LapReviewLap? Reference
@@ -101,10 +105,14 @@ public LapReviewLap? Reference
get => _reference;
set
{
- if (_disposed || !Set(ref _reference, value)) return;
- _referenceCursor = 0;
- _referenceMapContacts = [];
- if (!_applying) RefreshComparison();
+ if (_disposed || Equals(_reference, value)) return;
+ ChangeScrubContext(() =>
+ {
+ Set(ref _reference, value);
+ _referenceCursor = 0;
+ _referenceMapContacts = [];
+ if (!_applying) RefreshComparison();
+ });
}
}
public string Status { get => _status; private set => Set(ref _status, value); }
@@ -224,6 +232,7 @@ private void RefreshCursor()
}
private void ClearReview()
{
+ ScrubContextChanging?.Invoke();
_applying = true;
try
{
@@ -238,7 +247,7 @@ private void ClearReview()
nameof(CanPin), nameof(CursorDetails), nameof(CursorText), nameof(SectionText), nameof(Plot) }) Changed(property);
RaiseCommands();
}
- finally { _applying = false; }
+ finally { _applying = false; NotifyScrubState(); ScrubContextChanged?.Invoke(); }
}
private void RefreshSection() => _ = AnalyzeAsync(compare: false);
diff --git a/src/Wisp.App/SupportReminderPopup.xaml b/src/Wisp.App/SupportReminderPopup.xaml
index 556359c..a23d092 100644
--- a/src/Wisp.App/SupportReminderPopup.xaml
+++ b/src/Wisp.App/SupportReminderPopup.xaml
@@ -11,8 +11,12 @@
-
+
+
+
diff --git a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
index caf9a07..6bd5901 100644
--- a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
@@ -311,7 +311,7 @@ public void DifferentCarsUseActualIndependentCursorsAndPreserveBenchmarkGuards(i
Assert.Contains("Pinned benchmark is not used", model.ReferenceStatus);
Assert.False(model.Plot.Comparison!.CanCompare);
Assert.Contains("different cars", model.SectionText);
- Assert.Contains("Cursors are independent", model.MapComparisonHint);
+ Assert.Contains("Matched sections and time delta are unavailable", model.MapComparisonHint);
Assert.DoesNotContain("Choose Run B", model.MapComparisonHint);
Assert.Null(model.MapPlotB.Comparison);
@@ -388,6 +388,131 @@ public void RunBWithoutRecordedPositionsDoesNotCreateAModelOrAskToSelectRunBAgai
Assert.DoesNotContain("Choose Run B", model.MapComparisonHint);
});
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void IndependentSliderTargetsHoldTheOtherLapAndReadTheirOwnValues(bool differentCar) => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var reference = LapReviewFixtures.Create(reference: true);
+ if (differentCar) reference = WithCar(reference, 4200);
+ model.SetRuns(LapReviewFixtures.Create(), reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ Assert.True(model.ScrubBoth);
+ model.ScrubCursor = 150;
+ var originalB = model.MapPlotB.Cursor;
+ model.ScrubA = true;
+ model.ScrubCursor = 250;
+ Assert.Equal(250, model.Cursor);
+ Assert.Equal(originalB, model.MapPlotB.Cursor);
+ Assert.Null(model.MapPlotB.CursorPositionOverride);
+ Assert.StartsWith("A · ", model.ScrubPosition);
+
+ var cursorEvents = 0;
+ model.CursorMoved += _ => cursorEvents++;
+ var metrics = model.Metrics[0];
+ model.ScrubB = true;
+ model.ScrubCursor = 320;
+ Assert.Equal(250, model.Cursor);
+ Assert.Equal(250, model.Plot.Cursor);
+ Assert.Equal(320, model.MapPlotB.Cursor);
+ Assert.Equal(0, cursorEvents);
+ Assert.Same(metrics, model.Metrics[0]);
+ Assert.False(model.IsBusy);
+ Assert.StartsWith("B · ", model.ScrubPosition);
+ Assert.Equal(model.Reference!.Points.Length - 1, model.MaximumScrubCursor);
+ foreach (var channel in new[] { LapReviewChannel.Speed, LapReviewChannel.Power, LapReviewChannel.LateralG, LapReviewChannel.Delta })
+ {
+ model.Channel = model.Channels.Single(c => c.Channel == channel);
+ var data = model.MapPlotB;
+ var expected = LapReviewPlot.Value(data.Lap!.Points[320], data, 320);
+ Assert.Equal($"{model.Channel.Label}: {RunPresentation.Number(expected, " " + LapReviewPlot.Unit(data))}", model.MapBCursorValue);
+ }
+ model.PickReferencePoint(340);
+ Assert.Equal(250, model.Cursor); Assert.Equal(340, model.ScrubCursor);
+ model.Cursor = 270;
+ Assert.Equal(340, model.MapPlotB.Cursor);
+ model.ScrubBoth = true;
+ model.ScrubCursor = 400;
+ Assert.Equal(400, model.Cursor);
+ Assert.NotEqual(340, model.MapPlotB.Cursor);
+ if (!differentCar)
+ {
+ Assert.Equal(model.Plot.Comparison!.Points[400].ReferencePointIndex, model.MapPlotB.Cursor);
+ Assert.NotNull(model.MapPlotB.CursorPositionOverride);
+ Assert.Contains("matching track positions", model.ScrubHint);
+ }
+ else
+ {
+ Assert.Null(model.MapPlotB.Comparison);
+ Assert.Contains("relative lap progress", model.ScrubHint);
+ Assert.Contains("unavailable", model.ScrubHint);
+ }
+ });
+
+ [Fact]
+ public void BothFallbackUsesRecordedDistanceAndIndependentModesDoNotChangeBenchmarkValidity() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var reference = WithCar(LapReviewFixtures.Create(reference: true), 4200);
+ reference = reference with { Samples = reference.Samples.Where((_, index) => index < 200 || index % 3 == 0).ToArray() };
+ model.SetRuns(LapReviewFixtures.Create(), reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ Assert.False(model.Plot.Comparison!.CanCompare);
+ Assert.True(model.Reference!.Points.Length < model.Lap!.Points.Length);
+ var target = model.MaximumCursor * 2 / 3;
+ model.ScrubCursor = target;
+ var progress = model.Lap.Points[target].DistanceMeters / model.Lap.RecordedDistanceMeters;
+ var distance = progress * model.Reference.RecordedDistanceMeters;
+ var expected = Enumerable.Range(0, model.Reference.Points.Length)
+ .MinBy(index => Math.Abs(model.Reference.Points[index].DistanceMeters - distance));
+ Assert.Equal(expected, model.MapPlotB.Cursor);
+ Assert.Null(model.MapPlotB.CursorPositionOverride);
+ Assert.Null(model.MapPlotB.Comparison);
+ Assert.Equal("No match", model.CursorDetails!.Delta);
+ Assert.Equal(-1, model.MapPlotB.SectionStart);
+ Assert.Equal(-1, model.MapPlotB.SectionEnd);
+ Assert.True(model.Reference.Quality.HasFlag(LapReviewQuality.TelemetryGap));
+ Assert.Equal(model.Reference.Points[^2].DistanceMeters, model.Reference.Points[^1].DistanceMeters);
+ model.ScrubCursor = model.MaximumScrubCursor;
+ Assert.Equal(model.Reference.Points.Length - 1, model.MapPlotB.Cursor);
+ model.ScrubCursor = 0;
+ Assert.Equal(0, model.MapPlotB.Cursor);
+ Assert.Contains("A · ", model.ScrubPosition); Assert.Contains("B · ", model.ScrubPosition);
+ });
+
+ [Fact]
+ public void LeavingSharedSpaceOrRemovingRunBRestoresTheNormalASlider() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ model.ScrubA = true; model.ScrubCursor = 120;
+ model.ScrubB = true; model.ScrubCursor = 250;
+ model.Is3D = false;
+ Assert.Equal(120, model.ScrubCursor); Assert.Equal(model.MaximumCursor, model.MaximumScrubCursor);
+ Assert.Equal(model.CursorDetails!.Position, model.ScrubPosition); Assert.Equal("", model.ScrubHint);
+ model.ScrubCursor = 180;
+ Assert.Equal(180, model.Cursor);
+ model.Is3D = true;
+ Assert.Equal(250, model.ScrubCursor);
+ model.Reference = null;
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.False(model.IsMapComparison);
+ Assert.Equal(180, model.ScrubCursor);
+ model.ScrubCursor = 200;
+ Assert.Equal(200, model.Cursor);
+ model.SetRuns(null, null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ Assert.Equal(0, model.ScrubCursor); Assert.Equal(0, model.MaximumScrubCursor);
+ });
+
private static RecordedRun WithCar(RecordedRun run, int carOrdinal) => run with
{
Samples = run.Samples.Select(sample => sample with { State = sample.State with { CarOrdinal = carOrdinal } }).ToArray()
diff --git a/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs b/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
index 518fdf3..37a0bab 100644
--- a/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
@@ -53,6 +53,10 @@ private static void AssertScrubbing()
model.Cursor = 45;
Assert.Equal(45d, slider.Value);
+ AssertTargetSwitching(view, slider, model);
+ model.Is3D = false;
+ model.Cursor = 45;
+
view.BeginScrub();
slider.SetCurrentValue(RangeBase.ValueProperty, 55d);
view.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent));
@@ -74,6 +78,53 @@ private static void AssertScrubbing()
finally { view.EndScrub(); view.DataContext = null; }
}
+ private static void AssertTargetSwitching(LapReviewView view, Slider slider, LapReviewViewModel model)
+ {
+ model.Is3D = true; model.SharedSpace = true;
+ model.ScrubA = true;
+ var b = model.MapPlotB.Cursor;
+ var updates = view.ScrubUpdates;
+ view.BeginScrub();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 75d);
+ Assert.Equal(45, model.Cursor);
+ model.ScrubB = true;
+ Assert.Equal(75, model.Cursor);
+ Assert.Equal(b, model.MapPlotB.Cursor);
+ Assert.Equal((double)b, slider.Value);
+ Assert.Equal(updates + 1, view.ScrubUpdates);
+ view.BeginScrub();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 120d);
+ model.ScrubA = true;
+ Assert.Equal(75, model.Cursor); Assert.Equal(120, model.MapPlotB.Cursor);
+ Assert.Equal(75d, slider.Value);
+
+ // A shorter replacement must not clamp A through B's slider maximum.
+ model.Cursor = 300;
+ model.Reference = model.Reference! with { Points = model.Reference.Points.Take(90).ToArray() };
+ Await(LapReviewComparisonTestSupport.Ready(model));
+ model.ScrubB = true;
+ Assert.Equal(89d, slider.Maximum);
+ Assert.Equal(300, model.Cursor);
+ Assert.Equal(0d, slider.Value);
+ view.BeginScrub();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 70d);
+ model.Is3D = false;
+ Assert.Equal(300, model.Cursor);
+ Assert.Equal(300d, slider.Value);
+ Assert.Equal((double)model.MaximumCursor, slider.Maximum);
+ model.Is3D = true;
+ Assert.Equal(70d, slider.Value);
+ Assert.Equal(70, model.MapPlotB.Cursor);
+ view.BeginScrub();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 80d);
+ model.Reference = null;
+ Await(LapReviewComparisonTestSupport.Ready(model));
+ Assert.Equal(300, model.Cursor);
+ Assert.Equal(300d, slider.Value);
+ Assert.False(model.IsMapComparison);
+ Assert.Equal((double)model.MaximumCursor, slider.Maximum);
+ }
+
private static void ReadyBindings(LapReviewView view, Slider slider)
{
Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.DataBind);
diff --git a/tests/Wisp.App.Tests/SupportReminderUiTests.cs b/tests/Wisp.App.Tests/SupportReminderUiTests.cs
index 7a19751..883c820 100644
--- a/tests/Wisp.App.Tests/SupportReminderUiTests.cs
+++ b/tests/Wisp.App.Tests/SupportReminderUiTests.cs
@@ -30,6 +30,7 @@ private static void VerifyPopup()
var message = Assert.IsType(popup.FindName("Message"));
var repository = Assert.IsType(popup.FindName("RepositoryButton"));
var dismiss = Assert.IsType(popup.FindName("DismissButton"));
+ var dismissIcon = Assert.IsType(dismiss.Content);
Assert.Equal("Wisp is a project I’ve spent months and thousands of hours working on. Keeping it free and accessible to everyone has always been a priority for me, so all I ask is: if you enjoy Wisp, please leave a star on GitHub :) -Views", message.Text);
Assert.Equal(TextWrapping.Wrap, message.TextWrapping);
Assert.Equal("Views2k/Wisp — star on GitHub", new ButtonAutomationPeer(repository).GetName());
@@ -45,6 +46,9 @@ private static void VerifyPopup()
AssertFits(card, popup);
AssertFits(scroll, card);
AssertFits(dismiss, card);
+ var iconBounds = dismissIcon.TransformToAncestor(dismiss).TransformBounds(new Rect(dismissIcon.RenderSize));
+ Assert.InRange(Math.Abs(iconBounds.Left + iconBounds.Width / 2 - dismiss.ActualWidth / 2), 0, 0.25);
+ Assert.InRange(Math.Abs(iconBounds.Top + iconBounds.Height / 2 - dismiss.ActualHeight / 2), 0, 0.25);
Assert.True(scroll.ViewportHeight > 0);
Assert.True(scroll.ExtentWidth <= scroll.ViewportWidth + 1);
Assert.True(content.ActualWidth <= scroll.ViewportWidth + 1);
@@ -82,6 +86,7 @@ private static void VerifyPopup()
Assert.Same(panel, card.Background);
Assert.Same(text, message.Foreground);
Assert.Same(text, repository.Foreground);
+ Assert.Same(dismiss.Foreground, dismissIcon.Stroke);
Assert.Same(raised, repository.Background);
Assert.Same(stroke, card.BorderBrush);
Assert.Same(stroke, repository.BorderBrush);
diff --git a/tools/Wisp.UiReview/LapRecordedComparisonReview.cs b/tools/Wisp.UiReview/LapRecordedComparisonReview.cs
index adc00e8..58669ae 100644
--- a/tools/Wisp.UiReview/LapRecordedComparisonReview.cs
+++ b/tools/Wisp.UiReview/LapRecordedComparisonReview.cs
@@ -97,6 +97,22 @@ internal static int Run(IReadOnlyList sourceFiles, string output, Func 0 && bDistance > 0, name + "/same-xyz-normalization");
+ var slider = (Slider)view.FindName("LapCursorSlider");
+ var buildsBeforeScrub = track.SceneBuildCount;
+ ((RadioButton)view.FindName("ScrubBoth")).SetCurrentValue(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty, true); Pump();
+ slider.SetCurrentValue(RangeBase.ValueProperty, (double)review.MaximumScrubCursor); Pump();
+ Check(review.Cursor == review.MaximumCursor && track.ComparisonData!.Cursor == review.Reference!.Points.Length - 1,
+ name + "/both-reaches-end-of-each-recorded-lap");
+ ((RadioButton)view.FindName("ScrubB")).SetCurrentValue(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty, true); Pump();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 80d); Pump();
+ Check(review.Cursor == review.MaximumCursor && track.ComparisonData!.Cursor == 80, name + "/B-slider-holds-A");
+ ((RadioButton)view.FindName("ScrubA")).SetCurrentValue(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty, true); Pump();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 150d); Pump();
+ Check(review.Cursor == 150 && track.ComparisonData!.Cursor == 80, name + "/A-slider-holds-B");
+ ((RadioButton)view.FindName("ScrubBoth")).SetCurrentValue(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty, true); Pump();
+ slider.SetCurrentValue(RangeBase.ValueProperty, review.MaximumScrubCursor / 2d); Pump();
+ Check(track.SceneBuildCount == buildsBeforeScrub && review.Plot.Comparison?.CanCompare == false,
+ name + "/scrubbing-preserves-meshes-and-benchmark-validity");
var cameraChecks = new List();
foreach (var focus in new[] { 0, 1, 2 })
{
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index 0482a32..076c772 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -54,6 +54,12 @@ internal static void Run(string unusedStoreDirectory, Action check
var sharedSpace = (CheckBox)view.FindName("SharedSpaceToggle");
var resetCamera = (Button)view.FindName("ResetCamera");
var cameraControls = (FrameworkElement)view.FindName("CameraControls");
+ var cameraAxes = (FrameworkElement)view.FindName("CameraAxes");
+ var mapToolbar = (FrameworkElement)view.FindName("MapToolbar");
+ var showContacts = (CheckBox)view.FindName("ShowContactsToggle");
+ var scrubBoth = (RadioButton)view.FindName("ScrubBoth");
+ var scrubA = (RadioButton)view.FindName("ScrubA");
+ var scrubB = (RadioButton)view.FindName("ScrubB");
var trace = (LapReviewPlot)view.FindName("Trace");
var view2D = (RadioButton)view.FindName("View2D");
var view3D = (RadioButton)view.FindName("View3D");
@@ -154,6 +160,16 @@ internal static void Run(string unusedStoreDirectory, Action check
name + "/normal-3d-preserves-original-map-and-reference");
check(sharedSpace.IsEnabled && sharedSpace.Visibility == Visibility.Visible && review.CanUseSharedSpace,
name + "/shared-space-toggle-available-for-comparison");
+ check(mapToolbar.IsAncestorOf(cameraControls) && cameraControls.IsAncestorOf(resetCamera) &&
+ mapToolbar.IsAncestorOf(showContacts) && mapToolbar.IsAncestorOf(showBoth) &&
+ cameraControls.Visibility == Visibility.Visible &&
+ !Descendants(view).OfType().Any(item => Equals(item.Header, "Camera controls")),
+ name + "/camera-buttons-and-contact-toggle-in-top-toolbar");
+ var elevation = (FrameworkElement)view.FindName("MapElevationSummary");
+ var elevationBottom = elevation.TranslatePoint(new Point(0, elevation.ActualHeight), view).Y;
+ var settingsTop = lapSettings.TranslatePoint(new Point(), view).Y;
+ check(settingsTop >= elevationBottom && settingsTop - elevationBottom <= 20 &&
+ !mapExportSurface.IsAncestorOf(lapSettings), name + "/lap-settings-below-elevation-outside-PNG");
CheckMapAndScrubber(); Capture("map-3d-normal");
VerifyMapImage("3d-normal", track3D);
var normalData = track3D.Data!;
@@ -177,6 +193,20 @@ internal static void Run(string unusedStoreDirectory, Action check
check(ReferenceEquals(track3D.ComparisonData?.Lap, review.Reference) && ReferenceEquals(track3D.ComparisonData?.Reference, review.Lap) &&
track3D.Data?.ShowReferencePath == false && track3D.ComparisonData?.ShowReferencePath == false,
name + "/3d-each-track-colors-its-own-lap");
+ check(scrubBoth.IsChecked == true && review.ScrubBoth, name + "/both-lap-scrubbing-default");
+ scrubB.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump();
+ var heldA = review.Cursor;
+ slider.SetCurrentValue(RangeBase.ValueProperty, 80d); Pump();
+ check(review.ScrubB && review.Cursor == heldA && track3D.ComparisonData?.Cursor == 80 &&
+ slider.Maximum == review.Reference!.Points.Length - 1, name + "/B-slider-holds-A-and-selects-B");
+ scrubA.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump();
+ slider.SetCurrentValue(RangeBase.ValueProperty, 150d); Pump();
+ check(review.ScrubA && review.Cursor == 150 && track3D.ComparisonData?.Cursor == 80,
+ name + "/A-slider-holds-B-and-selects-A");
+ scrubBoth.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump();
+ slider.SetCurrentValue(RangeBase.ValueProperty, (double)selectedCursor); Pump();
+ check(review.ScrubBoth && track3D.ComparisonData?.Cursor == review.MapPlotB.Cursor,
+ name + "/both-slider-restores-paired-cursors");
CheckSharedColorScale();
var camera = (track3D.Yaw, track3D.Pitch, track3D.Roll, track3D.ZoomFactor);
track3D.Yaw = camera.Yaw + 35; track3D.Pitch = camera.Pitch + 8; track3D.Roll = camera.Roll + 12;
@@ -330,7 +360,7 @@ void CheckSharedColorScale()
}
bool CameraBindingsMatch()
{
- var controls = Descendants(cameraControls).OfType().ToArray();
+ var controls = Descendants(cameraAxes).OfType().ToArray();
return new[] { (Name: "3D camera yaw", Property: nameof(LapReviewTrack3D.Yaw), Value: track3D.Yaw),
(Name: "3D camera pitch", Property: nameof(LapReviewTrack3D.Pitch), Value: track3D.Pitch),
(Name: "3D camera roll", Property: nameof(LapReviewTrack3D.Roll), Value: track3D.Roll) }
@@ -447,6 +477,9 @@ void VerifyMapImage(string mode, params FrameworkElement[] maps)
check(!review.HasLap && !review.PinCommand.CanExecute(null) && !review.SectionStartCommand.CanExecute(null), "empty-review-disables-commands");
check(!channel.IsEnabled && !slider.IsEnabled, "empty-review-disables-channel-and-cursor");
check(!view2D.IsEnabled && !view3D.IsEnabled && !saveMap.IsEnabled, "empty-review-disables-map-tools");
+ check(((Expander)view.FindName("LapSettings")).IsEnabled &&
+ Descendants((Expander)view.FindName("LapSettings")).OfType().All(item => item.IsEnabled),
+ "empty-review-keeps-recording-settings-available");
check(!laps.HasItems && !references.HasItems && !laps.IsEnabled && !references.IsEnabled, "empty-lap-selectors-disabled");
check(emptyLapHint.Visibility == Visibility.Visible && emptyReferenceHint.Visibility == Visibility.Visible &&
!emptyLapHint.IsHitTestVisible && !emptyReferenceHint.IsHitTestVisible, "empty-lap-selectors-show-passive-hints");
From 8eb38d790c2e352723687b73d64eb555d31fd180 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Tue, 6 Oct 2026 23:38:08 -0500
Subject: [PATCH 12/15] Simplify lap review layout and integrate divider resize
handle
---
installer/PrivateShiftCaptureGuide.txt | 21 +++---
src/Wisp.App/Runs/LapReviewView.xaml | 81 +++++++++++-------------
src/Wisp.App/Runs/LapReviewView.xaml.cs | 8 +++
tools/Wisp.UiReview/LapReviewUiChecks.cs | 39 ++++++------
4 files changed, 76 insertions(+), 73 deletions(-)
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index f95c19a..920bb4e 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -1,5 +1,5 @@
Wisp 2.6.4 Lap review 3D test
-Private build ID: lap-review-3d-20261006-05
+Private build ID: lap-review-3d-20261006-06
This is a private test build, not a public release. It updates your existing
Wisp installation and keeps your settings, profiles, calibrations, runs and
@@ -28,7 +28,9 @@ Changes
- Keep camera zoom steady while rotating either the shared overview or a focused lap.
- Choose Both, A or B beside Lap position to move paired or independent cursors.
- Use zoom, reset, Show both laps and Show contacts directly from the top toolbar.
-- Find Lap settings and benchmark directly below the elevation summary.
+- View the channel graph directly below the legend, with Lap settings and
+ benchmark beneath it. Drag the clearly marked divider handle to resize the map.
+- Use mouse camera gestures without manual angle sliders or a contact editor.
- Center the close icon in the A note from Views popup.
Controls
@@ -38,24 +40,25 @@ Relative progress does not establish a valid time delta or matched section.
Drag: rotate. Shift-drag or right-drag: pan. Ctrl-drag: roll.
Click the map before using scroll to zoom; otherwise the wheel scrolls the page.
Double-click or Reset camera: restore the workspace camera.
-Drag the grip below the map to resize it. Focus the grip and use Up/Down to
+Drag Resize map on the divider to resize it. Focus the grip and use Up/Down to
adjust the height, or Home to restore automatic sizing.
Click the line: select a point. Arrow keys: move along the lap.
-Home/End: first/last point. Camera controls also provide axis sliders.
+Home/End: first/last point.
Choose Run B using Compare above, or choose a reference in Lap settings and
benchmark, then enable Shared space in 3D. Both tracks remain in the same scene,
laid out side by side. With Shared space off, the original single-lap view remains.
Click a track to bring the camera toward it. Scroll back out, choose Show
-both laps or press Escape to frame both again. Rotation, pan and axis
-controls affect the shared camera. PNG saves the current camera view.
-Contact editing is labelled Run A; each map shows its own saved markers.
+both laps or press Escape to frame both again. Rotation and pan
+gestures affect the shared camera. PNG saves the current camera view.
+Each map shows its recorded and saved contact markers. Show contacts hides
+or shows those markers; this view does not add or remove saved contacts.
Suggested checks
1. Open an existing saved lap in Runs and expand Lap review.
2. Switch between 2D and 3D. Rotate and zoom, then reset the view.
3. Change channels and wheels. Select a point, compare Run B, select a section
and open it in graphs. The selected point/section should stay consistent.
-4. Mark a known contact, reopen the run and remove the marker.
+4. Toggle Show contacts and check the recorded markers on each lap.
5. Save a PNG in both modes. Check the current view, colors and legend.
6. Record a new lap on your usual Steam or Xbox PC installation. Object
contacts use the same Data Out fields on both editions.
@@ -64,7 +67,7 @@ Contact coverage
Forza reports velocity loss and mass for breakable-object impacts. Those
appear as Object contact in new recordings. Possible contact is an estimate
from movement; it is not a verified wall/car collision. Old recordings did
-not retain the object-impact fields. Mark known contacts where needed.
+not retain the object-impact fields. Previously saved markers remain visible.
Saved-run compatibility
Existing saved runs remain readable and keep their original format. New runs
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml b/src/Wisp.App/Runs/LapReviewView.xaml
index 1080159..b417ee9 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml
+++ b/src/Wisp.App/Runs/LapReviewView.xaml
@@ -5,6 +5,7 @@
xmlns:local="clr-namespace:Wisp.App.Runs">
+
+
@@ -82,8 +88,8 @@
-
-
+
+
@@ -106,6 +112,7 @@
+
@@ -136,10 +143,10 @@
-
+
-
-
+
+
@@ -152,6 +159,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -198,49 +229,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index 73d4f99..02d21b7 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -93,6 +93,14 @@ internal BitmapSource CaptureMapImage()
}
}
+public sealed class LapReviewResizeGripMarginConverter : IValueConverter
+{
+ // Align the sibling handle with the legend divider while excluding it from PNG exports.
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
+ new Thickness(16, 0, 16, 16 + (value is double height && double.IsFinite(height) ? Math.Max(0, height - 28) : 0));
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => Binding.DoNothing;
+}
+
public sealed class LapReviewCompactViewportConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index 076c772..57f2f45 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -5,7 +5,6 @@
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
-using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -54,7 +53,6 @@ internal static void Run(string unusedStoreDirectory, Action check
var sharedSpace = (CheckBox)view.FindName("SharedSpaceToggle");
var resetCamera = (Button)view.FindName("ResetCamera");
var cameraControls = (FrameworkElement)view.FindName("CameraControls");
- var cameraAxes = (FrameworkElement)view.FindName("CameraAxes");
var mapToolbar = (FrameworkElement)view.FindName("MapToolbar");
var showContacts = (CheckBox)view.FindName("ShowContactsToggle");
var scrubBoth = (RadioButton)view.FindName("ScrubBoth");
@@ -167,9 +165,25 @@ internal static void Run(string unusedStoreDirectory, Action check
name + "/camera-buttons-and-contact-toggle-in-top-toolbar");
var elevation = (FrameworkElement)view.FindName("MapElevationSummary");
var elevationBottom = elevation.TranslatePoint(new Point(0, elevation.ActualHeight), view).Y;
+ var traceTop = trace.TranslatePoint(new Point(), view).Y;
+ var traceBottom = trace.TranslatePoint(new Point(0, trace.ActualHeight), view).Y;
var settingsTop = lapSettings.TranslatePoint(new Point(), view).Y;
- check(settingsTop >= elevationBottom && settingsTop - elevationBottom <= 20 &&
- !mapExportSurface.IsAncestorOf(lapSettings), name + "/lap-settings-below-elevation-outside-PNG");
+ var resizeGrip = (Thumb)view.FindName("MapResizeGrip");
+ var divider = (FrameworkElement)view.FindName("MapDivider");
+ check(traceTop >= elevationBottom && traceTop - elevationBottom <= 24 &&
+ settingsTop >= traceBottom && settingsTop - traceBottom <= 16,
+ name + "/legend-then-graph-then-settings");
+ check(!mapExportSurface.IsAncestorOf(lapSettings) && !mapExportSurface.IsAncestorOf(trace) &&
+ !mapExportSurface.IsAncestorOf(resizeGrip), name + "/graph-settings-and-resize-control-outside-PNG");
+ check(Math.Abs(resizeGrip.TranslatePoint(new Point(0, resizeGrip.ActualHeight / 2), view).Y -
+ divider.TranslatePoint(new Point(0, divider.ActualHeight / 2), view).Y) < .5,
+ name + "/resize-handle-centered-on-divider");
+ check(view.FindName("CameraAxes") is null && !Descendants(view).OfType().Any(item =>
+ AutomationProperties.GetName(item).StartsWith("3D camera", StringComparison.Ordinal)) &&
+ !Descendants(view).OfType().Any(item => AutomationProperties.GetName(item) == "Lap contact markers") &&
+ !Descendants(view).OfType().Any(item => ReferenceEquals(item.Command, review.MarkContactCommand) ||
+ ReferenceEquals(item.Command, review.RemoveContactCommand)), name + "/no-manual-angle-or-contact-editors");
+ ScrollTo(elevation); Capture("legend-graph-settings");
CheckMapAndScrubber(); Capture("map-3d-normal");
VerifyMapImage("3d-normal", track3D);
var normalData = track3D.Data!;
@@ -249,8 +263,7 @@ internal static void Run(string unusedStoreDirectory, Action check
name + "/3d-overview-projects-tracks-side-by-side");
track3D.FocusLap(1); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
check(track3D.FocusedLap == 1 && track3D.RenderSize == overviewSize && showBoth.Visibility == Visibility.Visible &&
- ProjectedTrackBounds(reference: false).Width > overviewA.Width * 1.15 &&
- CameraBindingsMatch(), name + "/focus-A-moves-shared-camera");
+ ProjectedTrackBounds(reference: false).Width > overviewA.Width * 1.15, name + "/focus-A-moves-shared-camera");
CheckBothGeometriesRetained();
ScrollTo(scrubBar); Capture("map-3d-focus-A");
VerifyMapImage("3d-focus-A", track3D);
@@ -261,8 +274,7 @@ internal static void Run(string unusedStoreDirectory, Action check
track3D.FocusLap(2); track3D.CompleteCameraMotionForTest(); Arrange(surface, size);
check(track3D.FocusedLap == 2 && track3D.RenderSize == overviewSize &&
- ProjectedTrackBounds(reference: true).Width > overviewB.Width * 1.15 &&
- CameraBindingsMatch(), name + "/focus-B-moves-shared-camera");
+ ProjectedTrackBounds(reference: true).Width > overviewB.Width * 1.15, name + "/focus-B-moves-shared-camera");
CheckBothGeometriesRetained();
var referenceCursor = track3D.ComparisonData!.Cursor;
var nextReference = Math.Clamp(referenceCursor + 1, 0, review.Reference!.Points.Length - 1);
@@ -358,17 +370,6 @@ void CheckSharedColorScale()
check(a is not null && b is not null && a.ColorRangeOverride is { } shared && b.ColorRangeOverride == shared &&
LapReviewColorRange.From(a) == LapReviewColorRange.From(b), name + "/A-B-share-value-color-range-" + review.Channel.Channel);
}
- bool CameraBindingsMatch()
- {
- var controls = Descendants(cameraAxes).OfType().ToArray();
- return new[] { (Name: "3D camera yaw", Property: nameof(LapReviewTrack3D.Yaw), Value: track3D.Yaw),
- (Name: "3D camera pitch", Property: nameof(LapReviewTrack3D.Pitch), Value: track3D.Pitch),
- (Name: "3D camera roll", Property: nameof(LapReviewTrack3D.Roll), Value: track3D.Roll) }
- .All(axis => controls.SingleOrDefault(control => AutomationProperties.GetName(control) == axis.Name) is { } slider &&
- slider.GetBindingExpression(RangeBase.ValueProperty) is { } binding &&
- ReferenceEquals(binding.DataItem, track3D) && binding.ParentBinding.Path?.Path == axis.Property &&
- binding.ParentBinding.Mode == BindingMode.TwoWay && Math.Abs(slider.Value - axis.Value) < 1e-9);
- }
Rect ProjectedTrackBounds(bool reference)
{
var points = (reference ? track3D.ComparisonData : track3D.Data)!.Lap!.Points;
From fbcd7ea48711fac993f491637c2a6e5b3fc96dbd Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Wed, 7 Oct 2026 00:49:54 -0500
Subject: [PATCH 13/15] Fix lap comparison navigation, picking, and recording
compatibility
---
docs/LAP-REVIEW-3D-PRIVATE.md | 47 +++-
docs/run-library-format.md | 10 +-
installer/PrivateShiftCaptureGuide.txt | 23 +-
src/Wisp.App/App.xaml | 25 ++
src/Wisp.App/Runs/LapReviewPalette.cs | 8 +-
src/Wisp.App/Runs/LapReviewPlot.cs | 8 +-
.../Runs/LapReviewTrack3D.Comparison.cs | 1 +
src/Wisp.App/Runs/LapReviewTrack3D.cs | 117 ++++++--
src/Wisp.App/Runs/LapReviewTrackGeometry.cs | 35 ++-
src/Wisp.App/Runs/LapReviewView.Workspace.cs | 16 +-
src/Wisp.App/Runs/LapReviewView.xaml | 15 +-
src/Wisp.App/Runs/LapReviewView.xaml.cs | 2 +-
src/Wisp.App/Runs/LapReviewViewModel.Map.cs | 97 ++-----
.../Runs/LapReviewViewModel.MapComparison.cs | 57 +++-
.../Runs/LapReviewViewModel.Scrubbing.cs | 84 ++++--
src/Wisp.App/Runs/LapReviewViewModel.cs | 21 +-
src/Wisp.App/Runs/RunStore.Contacts.cs | 34 ---
src/Wisp.App/Runs/RunStore.cs | 19 +-
src/Wisp.App/Runs/RunWorkspaceSettings.cs | 4 +-
src/Wisp.App/Runs/RunsViewModel.Laps.cs | 6 -
src/Wisp.App/Runs/RunsViewModel.Metadata.cs | 3 -
src/Wisp.App/Runs/RunsViewModel.Workspace.cs | 1 +
src/Wisp.Core/Runs/LapReviewContacts.cs | 31 ++-
.../Wisp.App.Tests/LapContactShutdownTests.cs | 168 ------------
.../LapReviewMapChannelsTests.cs | 5 +-
.../LapReviewMapComparisonTests.cs | 255 +++++++++++++++++-
.../LapReviewPlotRenderingTests.cs | 23 ++
.../Wisp.App.Tests/LapReviewScrubbingTests.cs | 27 +-
tests/Wisp.App.Tests/LapReviewTrack3DTests.cs | 113 +++++++-
.../LapReviewTrackGeometryTests.cs | 40 +++
.../RunRecordingOptionsTests.cs | 2 +
tests/Wisp.App.Tests/RunStoreContactsTests.cs | 243 +++++++++++------
.../Wisp.App.Tests/RunTuneAttachmentTests.cs | 2 +-
.../Wisp.Core.Tests/LapReviewContactsTests.cs | 66 +++++
.../RunWorkspaceSettingsTests.cs | 17 ++
.../LapRecordedComparisonReview.cs | 5 +
tools/Wisp.UiReview/LapReviewUiChecks.cs | 37 ++-
37 files changed, 1172 insertions(+), 495 deletions(-)
delete mode 100644 src/Wisp.App/Runs/RunStore.Contacts.cs
delete mode 100644 tests/Wisp.App.Tests/LapContactShutdownTests.cs
diff --git a/docs/LAP-REVIEW-3D-PRIVATE.md b/docs/LAP-REVIEW-3D-PRIVATE.md
index 2096ec9..1f7236d 100644
--- a/docs/LAP-REVIEW-3D-PRIVATE.md
+++ b/docs/LAP-REVIEW-3D-PRIVATE.md
@@ -12,6 +12,34 @@ Horsepower, torque and elevation are additional channels. PNG export includes
the current camera, channel legend and selected-point value, uses a bounded image,
and saves atomically without overwriting existing files.
+## Private 07 audit fixes
+
+Verified defects and fixes from the private 06 audit and follow-up checks:
+
+- Unmatched Both mode updated B only through the slider. All A selection paths
+ now update paired progress; selecting B works symmetrically. Independent A/B
+ keeps the other cursor fixed. Relative progress does not enable timing matches.
+- Matched B keyboard steps could map back to the same A sample. Directional
+ navigation now advances through valid matches and preserves missing-match gaps.
+- Default tooltips used a light system background with Wisp's light text. Tooltips
+ now use themed colors, wrapping and bounded width; the full-map tooltip is removed.
+- Shared-space geometry could be reused after switching to a single track.
+ Preparation now includes layout mode in its identity before reusing a scene.
+- Track picking ignored depth. Segment picking prefers the visible overpass;
+ foreground annotations retain hit priority. Empty space focuses without selection.
+- Distant world positions distorted shared-space scale. Each lap is horizontally
+ centered with one physical scale and unchanged recorded elevation.
+- Map height was lost on reopen; it is now bounded and saved when resizing ends.
+ Compact upward resizing no longer jumps to a larger minimum. Home restores auto size.
+- Elevation values and legends use feet with mph, meters otherwise. Geometry keeps
+ equal physical scale on all axes.
+- Filled and hollow contact stars distinguish reported/saved evidence from estimates.
+ Unused manual-editor commands, storage method and advice have been removed.
+
+Wheel zoom still requires map focus so page scrolling works naturally. Right-click
+or click empty map space to focus without changing the lap point; this is explained
+beside the controls. Contact-format corrections and evidence limits are below.
+
## Contact evidence
The official FH6 Data Out layout documents SmashableVelDiff and SmashableMass:
@@ -19,14 +47,21 @@ https://support.forza.net/hc/en-us/articles/51744149102611-Forza-Horizon-6-Data-
These fields report breakable-object velocity loss and mass, not a general
wall/car collision flag. Object contact requires a continuous observed
-zero-to-positive impulse. Possible contact is a conservative movement estimate;
-manual Contact markers remain available. No universal collision accuracy is claimed.
+zero-to-positive impulse. A repeated game tick is accepted only with unchanged
+sample time, identity and position (within 1 cm), without a gap or clock rewind;
+repeated lap-clock values do not discard an otherwise continuous object impulse.
+Possible contact is a conservative movement estimate. Existing Contact annotations
+in saved or imported runs remain visible; the manual contact editor has been removed.
+No universal collision accuracy is claimed.
Historical runs discarded these fields and cannot retrospectively recover them.
New journals declare run schema 3 before recording samples, preventing older
readers from recovering a partial prefix and discarding newer contact data.
-Legacy v1/v2 runs stay readable and retain their format when editing annotations.
-This private build or newer is required to open newly recorded v3 runs.
+Finalized recordings and recovered journals with no positive object field omit
+the optional fields and use schema 1 or 2, preserving Wisp 2.6.4 readability.
+If either field is positive, schema 3 retains every object sample, including zero
+baselines, and requires this private build or newer. Existing finalized files and
+imports retain their format and data during metadata edits; no migration is performed.
## Platform assessment
@@ -40,8 +75,8 @@ coverage of walls, other cars, repeated impacts or dropped packets.
## Verification boundary
Focused checks cover existing channels and interactions, 3D projection/elevation,
-color pixels, stale asynchronous scene protection, contact annotation persistence,
-shutdown during save, schema recovery/import/export and PNG output. The private
+color pixels, stale asynchronous scene protection, retained contact annotations,
+repeated-clock object impulses, schema recovery/import/export and PNG output. The private
installer retains the standard full packaging gates. Detailed results and source
revision are retained with the delivered installer.
diff --git a/docs/run-library-format.md b/docs/run-library-format.md
index 5b3b4e5..3fb315e 100644
--- a/docs/run-library-format.md
+++ b/docs/run-library-format.md
@@ -4,13 +4,19 @@ Wisp run files and library archives preserve recorded telemetry and its metadata
## Single run: `.wisprun`
-The existing single-run format remains unchanged: a gzip-compressed UTF-8 JSON Lines file.
+The single-run container is a gzip-compressed UTF-8 JSON Lines file.
-1. The first line is a `RecordedRun` object. `schemaVersion` is currently `1`; `samples` must be an empty array. The header carries `id`, `name`, `tune`, `notes`, `startedAtUtc`, `finishReason`, `isIncomplete`, `rejectedDatagrams`, `droppedDatagrams`, and `markers`.
+1. The first line is a `RecordedRun` object; `samples` must be an empty array. The header carries `schemaVersion`, `id`, `name`, `tune`, optional `tuneAttachment`, `notes`, `startedAtUtc`, `finishReason`, `isIncomplete`, `rejectedDatagrams`, `droppedDatagrams`, `lapTimingMode`, and `markers`.
2. Every subsequent line is one `RunSample`, in recording order. Its fields are `elapsedSeconds`, `segment`, `isDriving`, `state`, `wheelSpeedMetersPerSecond`, `frontRadiusMeters`, and `rearRadiusMeters`. `state` contains the serialized `VehicleState` fields; values are not rounded for export.
JSON uses camel-case property names and string enum names. Unknown properties and unsupported schema versions are rejected rather than silently discarded. Existing Wisp validation also checks finite/bounded numeric data, chronological samples and markers, consistent car/drivetrain identity, plausible calibration, and metadata lengths. Current definitions live in `src/Wisp.Core/Runs/RunModels.cs`, `src/Wisp.Core/VehicleState.cs`, and `src/Wisp.App/Runs/RunStore.cs`.
+Schema 1 stores telemetry without a structured tune attachment. Schema 2 adds that attachment. Schema 3 permits optional breakable-object velocity-loss and mass fields, with or without a tune attachment. Missing object fields mean unavailable evidence, not a measured zero. These fields are validated as a pair; invalid or partial values are rejected.
+
+When finalizing a new recording or automatic lap, Wisp retains schema 3 if either object field is positive in any sample. All surrounding zero baselines remain intact, so an observed zero-to-positive contact can still be identified. If neither field is ever positive, Wisp omits these optional fields and saves schema 1 or 2 as appropriate. Those completed files remain readable in Wisp 2.6.4; files retaining schema 3 require the contact-capable build. Existing finalized files and imports are not downgraded, and metadata edits preserve their schema and telemetry.
+
+New recording journals declare schema 3 from the start, so older readers cannot recover a prefix and silently discard later object evidence. Recovery validates its retained samples before choosing the finalized format by the same rule and marks the run incomplete. Unsupported or unrecoverable journals remain on disk.
+
## Library archive: `.zip`, version 1
This is a standard ZIP containing one `manifest.json` and one existing `.wisprun` file per run. Tools that understand ZIP can extract the individual run files without implementing a new telemetry serialization format.
diff --git a/installer/PrivateShiftCaptureGuide.txt b/installer/PrivateShiftCaptureGuide.txt
index 920bb4e..0277db2 100644
--- a/installer/PrivateShiftCaptureGuide.txt
+++ b/installer/PrivateShiftCaptureGuide.txt
@@ -1,5 +1,5 @@
Wisp 2.6.4 Lap review 3D test
-Private build ID: lap-review-3d-20261006-06
+Private build ID: lap-review-3d-20261006-07
This is a private test build, not a public release. It updates your existing
Wisp installation and keeps your settings, profiles, calibrations, runs and
@@ -32,13 +32,20 @@ Changes
benchmark beneath it. Drag the clearly marked divider handle to resize the map.
- Use mouse camera gestures without manual angle sliders or a contact editor.
- Center the close icon in the A note from Views popup.
+- Move both lap cursors with track/graph clicks, keyboard navigation and the slider.
+- Step through slower comparison laps without sticking on the same sample.
+- Keep the map height when reopening the view, and show imperial elevation in feet.
+- Distinguish reported/saved contacts from estimated contacts in the legend.
+- Select the visible track at overpasses and restore the single-map layout correctly.
+- Read styled, wrapping tooltips without a blank white bar over the track.
Controls
Lap position: Both moves paired cursors; A or B moves only that lap. Both uses
matched positions where available, otherwise relative recorded lap distance.
Relative progress does not establish a valid time delta or matched section.
Drag: rotate. Shift-drag or right-drag: pan. Ctrl-drag: roll.
-Click the map before using scroll to zoom; otherwise the wheel scrolls the page.
+Right-click or click empty map space to enable scroll zoom without selecting a
+lap point. Otherwise the wheel scrolls the page.
Double-click or Reset camera: restore the workspace camera.
Drag Resize map on the divider to resize it. Focus the grip and use Up/Down to
adjust the height, or Home to restore automatic sizing.
@@ -70,11 +77,13 @@ from movement; it is not a verified wall/car collision. Old recordings did
not retain the object-impact fields. Previously saved markers remain visible.
Saved-run compatibility
-Existing saved runs remain readable and keep their original format. New runs
-with object-contact telemetry use run format 3 and require this build or newer.
-If you reinstall public 2.6.4, those new runs are kept on disk but cannot be
-opened there. Return to this build to review them. Exported runs keep the same
-version requirement.
+Existing saved runs retain their original format. New finalized runs without
+positive object-impact telemetry omit those optional fields and remain readable
+in public 2.6.4. Runs with positive object-impact telemetry use run format 3 and
+require this build or newer. Public 2.6.4 keeps those files but cannot open them.
+Recording journals declare format 3 from the start so older Wisp cannot recover
+an incomplete prefix and lose contact data. This build safely recovers them.
+Existing format-3 files are preserved rather than rewritten during metadata edits.
The 3D line uses recorded world positions at equal scale on all axes. Its floor
projection is a visual reference, not a terrain model. Dense laps are simplified
diff --git a/src/Wisp.App/App.xaml b/src/Wisp.App/App.xaml
index 161f084..a01b85b 100644
--- a/src/Wisp.App/App.xaml
+++ b/src/Wisp.App/App.xaml
@@ -98,6 +98,31 @@
+
+
+
+
+
+
+
+
+
+
@@ -143,7 +149,7 @@
-
+
@@ -154,7 +160,8 @@
-
+
+
@@ -162,7 +169,7 @@
diff --git a/src/Wisp.App/Runs/LapReviewView.xaml.cs b/src/Wisp.App/Runs/LapReviewView.xaml.cs
index 02d21b7..0feaf59 100644
--- a/src/Wisp.App/Runs/LapReviewView.xaml.cs
+++ b/src/Wisp.App/Runs/LapReviewView.xaml.cs
@@ -113,7 +113,7 @@ public sealed class LapReviewMapHeightConverter : IMultiValueConverter
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length > 1 && values[1] is double requested && double.IsFinite(requested))
- return Math.Clamp(requested, 180, 1400);
+ return Math.Clamp(requested, 70, 1400);
var overhead = 235d;
if (values.Length >= 6 && values[2] is double toolbar && values[3] is double scrub &&
values[4] is double readout && values[5] is bool compact)
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
index ab232fe..9efa755 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Map.cs
@@ -1,19 +1,15 @@
-using System.Windows.Input;
+using Wisp.Core;
using Wisp.Core.Runs;
namespace Wisp.App.Runs;
-public sealed record LapContactChoice(int PointIndex, string Label);
-
public sealed partial class LapReviewViewModel
{
- private bool _is3D, _showContacts = true, _savingContact, _contactClosing;
- private Task _contactSaveTask = Task.CompletedTask;
+ private bool _is3D, _showContacts = true;
private LapReviewLap? _contactLap;
private RunMarker[]? _contactMarkers;
private LapReviewContact[] _contacts = [];
private LapReviewPlotData? _legendData;
- private LapContactChoice? _selectedContact;
private string _mapStatus = "";
public bool Is3D { get => _is3D; set { if (_is3D != value) ChangeScrubContext(() => { Set(ref _is3D, value); Changed(nameof(Is2D)); RefreshMapMode(); }); } }
public bool Is2D { get => !Is3D; set { if (value) Is3D = false; } }
@@ -27,18 +23,18 @@ public sealed partial class LapReviewViewModel
$"{Channel.Label}: {RunPresentation.Number(LapReviewPlot.Value(lap.Points[Cursor], Plot, Cursor), " " + LapReviewPlot.Unit(Plot))}" : "";
public string ElevationSummary { get; private set; } = "";
public bool HasMapReference => !IsMapComparison && Plot.HasDistinctReference;
- public string ContactSummary => _contacts.Length == 0 ? "No contact markers" : $"{_contacts.Length} contact marker{(_contacts.Length == 1 ? "" : "s")}";
- public IReadOnlyList ContactChoices { get; private set; } = [];
- public LapContactChoice? SelectedContact
+ public double MapHeight
{
- get => _selectedContact;
- set { if (Set(ref _selectedContact, value) && value is not null) Cursor = value.PointIndex; }
+ get => _settings.RunWorkspace.LapMapHeight > 0 ? _settings.RunWorkspace.LapMapHeight : double.NaN;
+ set
+ {
+ var height = double.IsFinite(value) ? Math.Clamp(value, 70, 1400) : 0;
+ if (_settings.RunWorkspace.LapMapHeight == height) return;
+ _settings.RunWorkspace.LapMapHeight = height;
+ Changed(nameof(MapHeight));
+ _save();
+ }
}
- public bool CanMarkContact => HasLap && !_disposed && !_savingContact && !_contactClosing && !IsBusy;
- public bool CanRemoveContact => CanMarkContact && _contacts.Any(c => c.PointIndex == Cursor && c.Kind == LapReviewContactKind.UserMarkedContact);
- public ICommand MarkContactCommand { get; private set; } = null!;
- public ICommand RemoveContactCommand { get; private set; } = null!;
- public event Action? ContactMarkersChanged;
private IReadOnlyList VisibleContacts
{
@@ -49,15 +45,6 @@ private IReadOnlyList VisibleContacts
}
}
- private void InitializeMap()
- {
- MarkContactCommand = new RunUiCommand(() => SetContactAsync(true), () => CanMarkContact,
- () => MapStatus = "The contact marker could not be saved. Try again.");
- RemoveContactCommand = new RunUiCommand(() => SetContactAsync(false), () => CanRemoveContact,
- () => MapStatus = "The contact marker could not be removed. Try again.");
- _commands.Add((RunUiCommand)MarkContactCommand); _commands.Add((RunUiCommand)RemoveContactCommand);
- }
-
private void EnsureContacts()
{
var markers = _run?.Markers;
@@ -65,58 +52,6 @@ private void EnsureContacts()
_contactLap = Lap; _contactMarkers = markers;
_contacts = Lap is null ? [] : CachedTelemetryContacts(Lap).Concat(
LapReviewContacts.FromMarkers(Lap, markers ?? [])).OrderBy(c => c.PointIndex).ToArray();
- ContactChoices = _contacts.Select(c => new LapContactChoice(c.PointIndex,
- $"{c.Label} · {c.DistanceMeters:0} m · {Lap!.Points[c.PointIndex].LapSeconds:0.000} s")).ToArray();
- _selectedContact = null;
- Changed(nameof(ContactChoices)); Changed(nameof(SelectedContact)); Changed(nameof(ContactSummary));
- }
-
- private Task SetContactAsync(bool marked)
- {
- if (!CanMarkContact || Lap is not { } lap || _run is not { } run) return Task.CompletedTask;
- var seconds = marked ? lap.Points[Cursor].RunSeconds :
- _contacts.First(c => c.PointIndex == Cursor && c.Kind == LapReviewContactKind.UserMarkedContact).RunSeconds;
- _savingContact = true;
- _contactSaveTask = SaveContactCoreAsync(run, seconds, marked);
- NotifyContactAvailability();
- return _contactSaveTask;
- }
-
- private async Task SaveContactCoreAsync(RecordedRun run, double seconds, bool marked)
- {
- // Establish the owning task before notifications can reenter close preparation.
- await Task.Yield();
- MapStatus = "Saving contact marker…";
- try
- {
- var markers = await _store.SetContactMarkerAsync(run.Id, seconds, marked);
- if (_disposed) return;
- if (_run?.Id == run.Id) _run = _run with { Markers = markers };
- ContactMarkersChanged?.Invoke(run.Id, markers);
- MapStatus = marked ? "Contact marker saved with the run." : "Contact marker removed.";
- Changed(nameof(Plot));
- }
- finally { _savingContact = false; if (!_disposed) NotifyContactAvailability(); }
- }
-
- internal Task PrepareToCloseContactsAsync()
- {
- _contactClosing = true;
- NotifyContactAvailability();
- // An earlier reported failure is not a pending edit. A failure during
- // this close attempt propagates so the normal shutdown guard keeps Wisp open.
- return _savingContact ? _contactSaveTask : Task.CompletedTask;
- }
-
- internal void CancelContactClosePreparation()
- {
- _contactClosing = false;
- if (!_disposed) NotifyContactAvailability();
- }
-
- private void NotifyContactAvailability()
- {
- RaiseCommands(); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
}
private void RefreshMapDetails()
@@ -136,17 +71,17 @@ private void RefreshMapDetails()
LegendMaximum = range.HasValues ? $"{range.Maximum:0.##} {LapReviewPlot.Unit(data)}" : "Unavailable";
var positions = IsMapComparison && Reference is { } reference
? data.Lap!.Points.Concat(reference.Points) : data.Lap?.Points ?? [];
+ var elevationFactor = LapReviewPlot.ElevationFactor(_settings.SpeedUnit);
+ var elevationUnit = _settings.SpeedUnit == SpeedUnit.MilesPerHour ? "ft" : "m";
ElevationSummary = data.Lap is { Points.Length: > 0 } ?
- $"{(IsMapComparison ? "Both laps’ recorded elevation" : "Recorded elevation")}: {positions.Min(p => p.Position.Y):0.0}–{positions.Max(p => p.Position.Y):0.0} m · true scale" : "";
+ $"{(IsMapComparison ? "Both laps’ recorded elevation" : "Recorded elevation")}: {positions.Min(p => p.Position.Y) * elevationFactor:0.0}–{positions.Max(p => p.Position.Y) * elevationFactor:0.0} {elevationUnit} · true scale" : "";
foreach (var name in new[] { nameof(LegendMinimum), nameof(LegendMaximum), nameof(LegendTitle), nameof(ElevationSummary), nameof(MapTitle), nameof(MapATitle), nameof(MapBTitle) }) Changed(name);
}
Changed(nameof(MapCursorValue)); Changed(nameof(MapBCursorValue)); Changed(nameof(MapPlotA)); Changed(nameof(MapPlotB));
Changed(nameof(ComparisonMapPlot));
Changed(nameof(IsMapComparison));
Changed(nameof(CanUseSharedSpace));
- Changed(nameof(HasMapComparison)); Changed(nameof(MapComparisonHint)); Changed(nameof(HasMapReference)); Changed(nameof(CanMarkContact)); Changed(nameof(CanRemoveContact));
+ Changed(nameof(HasMapComparison)); Changed(nameof(MapComparisonHint)); Changed(nameof(HasMapReference));
NotifyScrubState();
- if (MarkContactCommand is RunUiCommand mark) mark.RaiseCanExecuteChanged();
- if (RemoveContactCommand is RunUiCommand remove) remove.RaiseCanExecuteChanged();
}
}
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
index 8b8aaaf..4f046e5 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.MapComparison.cs
@@ -72,18 +72,69 @@ private LapReviewPlotData ReferencePlot()
public void PickReferencePoint(int index)
{
- if (!HasMapComparison || Reference is not { } reference || (uint)index >= (uint)reference.Points.Length) return;
+ if (_disposed || !HasMapComparison || Reference is not { } reference || (uint)index >= (uint)reference.Points.Length) return;
+ if (UsesRelativeReferenceCursor) { Cursor = RelativePrimaryCursor(index); return; }
if (!UsesMatchedReferenceCursor)
{
SetReferenceCursor(index);
return;
}
- if (_reverseComparison!.Points.ElementAtOrDefault(index)?.ReferencePointIndex is not { } matched) return;
- Cursor = matched;
+ var matched = NearestPrimaryIndexForReference(index);
+ if (matched >= 0) Cursor = matched;
+ }
+
+ public void NavigateReferenceCursor(LapReviewCursorNavigation navigation)
+ {
+ if (_disposed || !HasMapComparison || Reference is not { Points.Length: > 0 } reference ||
+ !Enum.IsDefined(navigation)) return;
+ var forward = navigation is LapReviewCursorNavigation.Next or LapReviewCursorNavigation.Start;
+ var endpoint = navigation is LapReviewCursorNavigation.Start or LapReviewCursorNavigation.End;
+ var current = MapPlotB.Cursor;
+ var requested = navigation switch
+ {
+ LapReviewCursorNavigation.Start => 0,
+ LapReviewCursorNavigation.End => reference.Points.Length - 1,
+ _ => Math.Clamp(current + (forward ? 1 : -1), 0, reference.Points.Length - 1)
+ };
+ if (UsesRelativeReferenceCursor) { NavigateRelativeReference(requested, forward, endpoint); return; }
+ if (!UsesMatchedReferenceCursor) { SetReferenceCursor(requested); return; }
+
+ // B can have more samples than A. Mapping its next sample to the lower A
+ // sample repeatedly would stick, so require progress in the requested direction.
+ var candidate = forward ? 0 : MaximumCursor;
+ if (!endpoint)
+ {
+ var nearest = current >= 0 ? NearestPrimaryIndexForReference(requested) : -1;
+ candidate = forward ? Math.Max(Cursor + 1, nearest) : Math.Min(Cursor - 1, nearest < 0 ? Cursor - 1 : nearest);
+ }
+ var previousSeconds = _comparison?.Points.ElementAtOrDefault(Cursor)?.ReferenceLapSeconds;
+ var plot = Plot;
+ for (var index = candidate; (uint)index <= (uint)MaximumCursor; index += forward ? 1 : -1)
+ {
+ if (LapReviewPlot.ReferencePosition(plot, index) is null ||
+ _comparison?.Points.ElementAtOrDefault(index)?.ReferenceLapSeconds is not { } seconds) continue;
+ if (!endpoint && previousSeconds is { } previous && (forward ? seconds <= previous : seconds >= previous)) continue;
+ Cursor = index;
+ return;
+ }
+ }
+
+ private int NearestPrimaryIndexForReference(int referenceIndex)
+ {
+ if (Lap is not { Points.Length: > 0 } lap ||
+ _reverseComparison?.Points.ElementAtOrDefault(referenceIndex) is not { ReferencePointIndex: { } lower, ReferenceLapSeconds: { } seconds } ||
+ (uint)lower >= (uint)lap.Points.Length || !double.IsFinite(seconds)) return -1;
+ var upper = Math.Min(lower + 1, lap.Points.Length - 1);
+ var nearest = Math.Abs(lap.Points[upper].LapSeconds - seconds) < Math.Abs(seconds - lap.Points[lower].LapSeconds) ? upper : lower;
+ var alternate = nearest == lower ? upper : lower;
+ // Keep A on an actual recorded sample and retain only genuine forward matches.
+ if (LapReviewPlot.ReferencePosition(Plot, nearest) is not null) return nearest;
+ return LapReviewPlot.ReferencePosition(Plot, alternate) is not null ? alternate : -1;
}
private void RefreshMapMode()
{
+ SynchronizeRelativeReferenceCursor(Cursor);
_legendData = null;
RefreshMapDetails();
Changed(nameof(CanUseSharedSpace));
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs b/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
index 90970cf..0f2b8c9 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.Scrubbing.cs
@@ -1,5 +1,9 @@
+using Wisp.Core.Runs;
+
namespace Wisp.App.Runs;
+public enum LapReviewCursorNavigation { Previous, Next, Start, End }
+
public sealed partial class LapReviewViewModel
{
private enum ScrubTarget { Both, A, B }
@@ -11,6 +15,7 @@ private enum ScrubTarget { Both, A, B }
public bool ScrubB { get => _scrubTarget == ScrubTarget.B; set { if (value) SetScrubTarget(ScrubTarget.B); } }
private bool ScrubsReferenceOnly => IsMapComparison && ScrubB;
private bool UsesMatchedReferenceCursor => HasMatchedMapComparison && (!IsMapComparison || ScrubBoth);
+ private bool UsesRelativeReferenceCursor => IsMapComparison && ScrubBoth && !HasMatchedMapComparison;
public int MaximumScrubCursor => ScrubsReferenceOnly ? Math.Max(0, (Reference?.Points.Length ?? 1) - 1) : MaximumCursor;
public int ScrubCursor
{
@@ -20,15 +25,7 @@ public int ScrubCursor
if (_disposed) return;
value = Math.Clamp(value, 0, MaximumScrubCursor);
if (ScrubsReferenceOnly) { SetReferenceCursor(value); return; }
- var referenceChanged = false;
- if (IsMapComparison && ScrubBoth && !HasMatchedMapComparison)
- {
- var reference = RelativeReferenceCursor(value);
- referenceChanged = _referenceCursor != reference;
- _referenceCursor = reference;
- }
- if (Cursor != value) Cursor = value;
- else if (referenceChanged) NotifyReferenceCursor();
+ Cursor = value;
}
}
public string ScrubPosition => !IsMapComparison ? CursorDetails?.Position ?? "" : ScrubA
@@ -83,29 +80,70 @@ private void NotifyReferenceCursor()
NotifyScrubState();
}
- private int RelativeReferenceCursor(int index)
+ private bool SynchronizeRelativeReferenceCursor(int primaryIndex)
+ {
+ if (!UsesRelativeReferenceCursor) return false;
+ var referenceIndex = RelativeReferenceCursor(primaryIndex);
+ if (_referenceCursor == referenceIndex) return false;
+ _referenceCursor = referenceIndex;
+ return true;
+ }
+
+ private int RelativeReferenceCursor(int index) => RelativeCursor(Lap, Reference, index);
+ private int RelativePrimaryCursor(int index) => RelativeCursor(Reference, Lap, index);
+
+ private static int RelativeCursor(LapReviewLap? source, LapReviewLap? target, int index)
{
- if (Lap is not { Points.Length: > 1 } lap || Reference is not { Points.Length: > 1 } reference) return 0;
- index = Math.Clamp(index, 0, lap.Points.Length - 1);
+ if (source is not { Points.Length: > 1 } || target is not { Points.Length: > 1 }) return 0;
+ index = Math.Clamp(index, 0, source.Points.Length - 1);
if (index == 0) return 0;
- if (index == lap.Points.Length - 1) return reference.Points.Length - 1;
- var first = lap.Points[0].DistanceMeters;
- var span = lap.Points[^1].DistanceMeters - first;
- if (!double.IsFinite(span) || span <= 0) return 0;
- var progress = Math.Clamp((lap.Points[index].DistanceMeters - first) / span, 0, 1);
+ if (index == source.Points.Length - 1) return target.Points.Length - 1;
+ var first = source.Points[0].DistanceMeters;
+ var span = source.Points[^1].DistanceMeters - first;
+ var targetFirst = target.Points[0].DistanceMeters;
+ var targetSpan = target.Points[^1].DistanceMeters - targetFirst;
+ if (!double.IsFinite(first) || !double.IsFinite(span) || span <= 0 ||
+ !double.IsFinite(source.Points[index].DistanceMeters) || !double.IsFinite(targetFirst) ||
+ !double.IsFinite(targetSpan) || targetSpan < 0) return 0;
+ var progress = Math.Clamp((source.Points[index].DistanceMeters - first) / span, 0, 1);
// Gaps and a stationary finish can share the same cumulative distance.
- if (progress >= 1) return reference.Points.Length - 1;
- var distance = reference.Points[0].DistanceMeters + progress *
- (reference.Points[^1].DistanceMeters - reference.Points[0].DistanceMeters);
+ if (progress >= 1) return target.Points.Length - 1;
+ var distance = targetFirst + progress * targetSpan;
// Recorded cumulative distance is monotonic; avoid scanning either lap while dragging.
- var low = 0; var high = reference.Points.Length - 1;
+ var low = 0; var high = target.Points.Length - 1;
while (low < high)
{
var middle = low + (high - low) / 2;
- if (reference.Points[middle].DistanceMeters < distance) low = middle + 1;
+ if (target.Points[middle].DistanceMeters < distance) low = middle + 1;
else high = middle;
}
- return low > 0 && distance - reference.Points[low - 1].DistanceMeters < reference.Points[low].DistanceMeters - distance ? low - 1 : low;
+ return low > 0 && distance - target.Points[low - 1].DistanceMeters < target.Points[low].DistanceMeters - distance ? low - 1 : low;
+ }
+
+ private void NavigateRelativeReference(int requested, bool forward, bool endpoint)
+ {
+ if (endpoint) { Cursor = forward ? 0 : MaximumCursor; return; }
+ var currentReference = RelativeReferenceCursor(Cursor);
+ var nearest = RelativePrimaryCursor(requested);
+ if (forward ? nearest > Cursor && RelativeReferenceCursor(nearest) > currentReference
+ : nearest < Cursor && RelativeReferenceCursor(nearest) < currentReference)
+ { Cursor = nearest; return; }
+
+ // Unequal densities or stationary spans can round back to the same A
+ // sample. Find the next representable shared progress without a full scan.
+ var low = forward ? Cursor + 1 : 0;
+ var high = forward ? MaximumCursor : Cursor - 1;
+ var selected = -1;
+ while (low <= high)
+ {
+ var middle = low + (high - low) / 2;
+ var value = RelativeReferenceCursor(middle);
+ var advances = forward ? value > currentReference : value < currentReference;
+ if (advances) selected = middle;
+ if (forward ? advances : !advances) high = middle - 1;
+ else low = middle + 1;
+ }
+ if (selected >= 0) Cursor = selected;
}
private string ReferenceCursorPosition()
diff --git a/src/Wisp.App/Runs/LapReviewViewModel.cs b/src/Wisp.App/Runs/LapReviewViewModel.cs
index 026ce62..e6d8166 100644
--- a/src/Wisp.App/Runs/LapReviewViewModel.cs
+++ b/src/Wisp.App/Runs/LapReviewViewModel.cs
@@ -65,7 +65,6 @@ public LapReviewViewModel(AppSettings settings, RunStore store, Action save)
UnpinCommand = Command(() => { _settings.LapReviewBenchmarkRunId = null; _save(); _ = LoadAsync(); }, () => _settings.LapReviewBenchmarkRunId is not null);
ShowGraphsCommand = Command(() => { if (Lap is { Points.Length: > 0 }) SectionChosen?.Invoke(Lap.Points[_sectionStart].RunSeconds, Lap.Points[_sectionEnd].RunSeconds); },
() => HasLap && _sectionEnd > _sectionStart && Lap!.Points[_sectionEnd].RunSeconds > Lap.Points[_sectionStart].RunSeconds);
- InitializeMap();
}
private RunUiCommand Command(Action action, Func? canExecute = null)
{
@@ -123,7 +122,24 @@ public LapReviewLap? Reference
public bool CanPin => !_disposed && !_busy && UsableBenchmark(Lap);
public bool IsBusy => _busy;
public int MaximumCursor => Math.Max(0, (Lap?.Points.Length ?? 1) - 1);
- public int Cursor { get => _cursor; set { if (!_disposed && Set(ref _cursor, Math.Clamp(value, 0, Math.Max(0, MaximumCursor)))) { RefreshCursor(); Changed(nameof(Plot)); if (Lap is { Points.Length: > 0 }) CursorMoved?.Invoke(Lap.Points[Cursor].RunSeconds); } } }
+ public int Cursor
+ {
+ get => _cursor;
+ set
+ {
+ if (_disposed) return;
+ value = Math.Clamp(value, 0, MaximumCursor);
+ // Every primary cursor entry point shares Both-mode synchronization:
+ // map/graph picking, keyboard navigation and the lap-position slider.
+ var referenceChanged = SynchronizeRelativeReferenceCursor(value);
+ if (Set(ref _cursor, value))
+ {
+ RefreshCursor(); Changed(nameof(Plot));
+ if (Lap is { Points.Length: > 0 }) CursorMoved?.Invoke(Lap.Points[Cursor].RunSeconds);
+ }
+ else if (referenceChanged) NotifyReferenceCursor();
+ }
+ }
public LapReviewCursorReadout? CursorDetails { get; private set; }
public string CursorText => CursorDetails is { } details ? $"{details.Position} · {details.Speed} · {details.Powertrain}" : "";
public string SectionText { get; private set; } = "";
@@ -212,6 +228,7 @@ private async Task LoadAsync()
private void RefreshComparison()
{
_comparison = _reverseComparison = null;
+ SynchronizeRelativeReferenceCursor(Cursor);
RefreshCursor(); Changed(nameof(Plot));
_ = AnalyzeAsync(compare: true);
}
diff --git a/src/Wisp.App/Runs/RunStore.Contacts.cs b/src/Wisp.App/Runs/RunStore.Contacts.cs
deleted file mode 100644
index 136fa5d..0000000
--- a/src/Wisp.App/Runs/RunStore.Contacts.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System.IO;
-using Wisp.Core.Runs;
-
-namespace Wisp.App.Runs;
-
-public sealed partial class RunStore
-{
- public Task SetContactMarkerAsync(Guid id, double elapsedSeconds, bool marked) => InBackground(async () =>
- {
- var run = await ReadAsync(RunPath(id), id).ConfigureAwait(false);
- if (!double.IsFinite(elapsedSeconds) || run.Samples.Length == 0 ||
- elapsedSeconds < run.Samples[0].ElapsedSeconds || elapsedSeconds > run.Samples[^1].ElapsedSeconds)
- throw new ArgumentOutOfRangeException(nameof(elapsedSeconds), "Choose a recorded position in this run.");
- var existing = run.Markers.Where(marker => IsContactAt(marker, elapsedSeconds)).ToArray();
- if (marked && existing.Length > 0 || !marked && existing.Length == 0) return run.Markers;
- RunMarker[] markers;
- if (marked)
- {
- if (run.Markers.Length >= MaximumMarkers)
- throw new InvalidDataException("This run has reached its marker limit. Remove a marker before adding another.");
- markers = run.Markers.Append(new(elapsedSeconds, LapReviewContacts.ContactMarkerLabel))
- .OrderBy(marker => marker.ElapsedSeconds).ToArray();
- }
- else markers = run.Markers.Where(marker => !IsContactAt(marker, elapsedSeconds)).ToArray();
- // Read and change only the latest saved markers under the store gate. A
- // stale review window must not overwrite concurrent notes or tune changes.
- await WriteAsync(run with { Markers = markers }, overwrite: true).ConfigureAwait(false);
- return markers;
- });
-
- private static bool IsContactAt(RunMarker marker, double seconds) =>
- string.Equals(marker.Label.Trim(), LapReviewContacts.ContactMarkerLabel, StringComparison.OrdinalIgnoreCase) &&
- Math.Abs(marker.ElapsedSeconds - seconds) <= .0001;
-}
diff --git a/src/Wisp.App/Runs/RunStore.cs b/src/Wisp.App/Runs/RunStore.cs
index 48e298b..504d0f2 100644
--- a/src/Wisp.App/Runs/RunStore.cs
+++ b/src/Wisp.App/Runs/RunStore.cs
@@ -86,7 +86,7 @@ public Task> ListAsync() => InBackground SaveAsync(RecordedRun run) => InBackground(async () =>
{
- await WriteAsync(run, overwrite: false).ConfigureAwait(false);
+ await WriteAsync(run, overwrite: false, finalizeContactFormat: true).ConfigureAwait(false);
return Summarize(run);
});
@@ -279,12 +279,25 @@ private static async Task ReadAsync(string path, Guid? expectedId =
return run;
}
- private async Task WriteAsync(RecordedRun run, bool overwrite)
+ private async Task WriteAsync(RecordedRun run, bool overwrite, bool finalizeContactFormat = false)
{
if ((run.SchemaVersion is RecordedRun.BaseSchemaVersion or RecordedRun.TuneAttachmentSchemaVersion) &&
ValidSchema(run) && run.Samples is { } samples && samples.Any(sample => sample?.State is { } state && HasContactData(state)))
run = run with { SchemaVersion = RecordedRun.CurrentSchemaVersion };
Validate(run);
+ // Journals retain v3 until every sample has been read. Only finalized new
+ // recordings without any positive object evidence can omit the optional
+ // fields; imports and edits of existing files retain their declared format.
+ if (finalizeContactFormat && run.SchemaVersion == RecordedRun.CurrentSchemaVersion &&
+ !run.Samples.Any(sample => sample.State.SmashableVelocityLossMetersPerSecond is > 0 || sample.State.SmashableMassKilograms is > 0))
+ run = run with
+ {
+ SchemaVersion = run.TuneAttachment is null ? RecordedRun.BaseSchemaVersion : RecordedRun.TuneAttachmentSchemaVersion,
+ Samples = run.Samples.Select(sample => HasContactData(sample.State) ? sample with
+ {
+ State = sample.State with { SmashableVelocityLossMetersPerSecond = null, SmashableMassKilograms = null }
+ } : sample).ToArray()
+ };
if (!overwrite) EnsureCapacity(run.Id);
var destination = RunPath(run.Id);
var temporary = Path.Combine(_directory, $".{run.Id:N}-{Guid.NewGuid():N}.tmp");
@@ -380,7 +393,7 @@ await WriteAsync(header with
IsIncomplete = true,
TuneAttachment = header.TuneAttachment is { } attachment ? attachment with { DrivingContinuityInterrupted = true } : null,
FinishReason = "Recovered after Wisp closed before the run finished"
- }, false).ConfigureAwait(false);
+ }, overwrite: false, finalizeContactFormat: true).ConfigureAwait(false);
reader.Dispose();
file.Dispose();
File.Delete(path);
diff --git a/src/Wisp.App/Runs/RunWorkspaceSettings.cs b/src/Wisp.App/Runs/RunWorkspaceSettings.cs
index 9537632..65ee647 100644
--- a/src/Wisp.App/Runs/RunWorkspaceSettings.cs
+++ b/src/Wisp.App/Runs/RunWorkspaceSettings.cs
@@ -66,12 +66,13 @@ public sealed class RunWorkspaceSettings
{
public RunWorkspacePreset Preset { get; set; } = RunWorkspacePreset.Overview;
public RunWorkspaceComparisonMode ComparisonMode { get; set; }
+ public double LapMapHeight { get; set; }
public List Panels { get; set; } = RunWorkspaceCatalog.CreatePanels(RunWorkspacePreset.Overview);
public RunWorkspaceSettings Clone()
{
var copy = new RunWorkspaceSettings
- { Preset = Preset, ComparisonMode = ComparisonMode, Panels = Panels?.Where(panel => panel is not null).Select(panel => panel.Clone()).ToList()! };
+ { Preset = Preset, ComparisonMode = ComparisonMode, LapMapHeight = LapMapHeight, Panels = Panels?.Where(panel => panel is not null).Select(panel => panel.Clone()).ToList()! };
copy.Normalize();
return copy;
}
@@ -82,6 +83,7 @@ public void Normalize()
if (!Enum.IsDefined(Preset) || Preset is RunWorkspacePreset.Acceleration or RunWorkspacePreset.Drifting)
Preset = RunWorkspacePreset.Overview;
if (!Enum.IsDefined(ComparisonMode)) ComparisonMode = RunWorkspaceComparisonMode.Overlay;
+ LapMapHeight = double.IsFinite(LapMapHeight) && LapMapHeight > 0 ? Math.Clamp(LapMapHeight, 70, 1400) : 0;
if (Panels is null) Panels = RunWorkspaceCatalog.CreatePanels(Preset);
var clean = new List(RunWorkspaceCatalog.MaximumModules);
var ids = new HashSet(StringComparer.Ordinal);
diff --git a/src/Wisp.App/Runs/RunsViewModel.Laps.cs b/src/Wisp.App/Runs/RunsViewModel.Laps.cs
index 6f13ea8..e8c7f15 100644
--- a/src/Wisp.App/Runs/RunsViewModel.Laps.cs
+++ b/src/Wisp.App/Runs/RunsViewModel.Laps.cs
@@ -9,12 +9,6 @@ public sealed partial class RunsViewModel
private void InitializeLapReview()
{
LapReview = new(_settings, _service.Store, () => PreferencesChanged?.Invoke(this, EventArgs.Empty));
- LapReview.ContactMarkersChanged += (id, markers) =>
- {
- if (_runA?.Id == id) _runA = _runA with { Markers = markers };
- if (_runB?.Id == id) _runB = _runB with { Markers = markers };
- RefreshMarkers();
- };
LapReview.CursorMoved += seconds =>
{
if (_lapCursorSync) return;
diff --git a/src/Wisp.App/Runs/RunsViewModel.Metadata.cs b/src/Wisp.App/Runs/RunsViewModel.Metadata.cs
index 24ec49a..0885d93 100644
--- a/src/Wisp.App/Runs/RunsViewModel.Metadata.cs
+++ b/src/Wisp.App/Runs/RunsViewModel.Metadata.cs
@@ -91,7 +91,6 @@ public Task PrepareToCloseMetadataAsync()
{
if (_metadataCloseTask is { IsCompleted: false }) return _metadataCloseTask;
_metadataClosing = true;
- var contactSave = LapReview.PrepareToCloseContactsAsync();
CancelCountdown("Countdown canceled because Wisp is closing.");
var revision = ++_metadataCloseRevision;
_metadataCloseTask = PrepareAsync();
@@ -105,7 +104,6 @@ async Task PrepareAsync()
await Task.Yield();
try
{
- await contactSave;
await _metadataRestoreTask;
foreach (var edit in _metadataEdits.Values.Where(edit => edit.Failed && !edit.Deleted)) edit.AttemptedRevision = -1;
await FlushMetadataAsync();
@@ -123,7 +121,6 @@ public void CancelMetadataClosePreparation()
{
_metadataCloseRevision++;
_metadataClosing = false;
- LapReview.CancelContactClosePreparation();
NotifyMetadataCloseAvailability();
RefreshStatus();
}
diff --git a/src/Wisp.App/Runs/RunsViewModel.Workspace.cs b/src/Wisp.App/Runs/RunsViewModel.Workspace.cs
index 9772fb6..3f0632f 100644
--- a/src/Wisp.App/Runs/RunsViewModel.Workspace.cs
+++ b/src/Wisp.App/Runs/RunsViewModel.Workspace.cs
@@ -135,6 +135,7 @@ private void PersistWorkspace(bool explicitSave = false)
{
Preset = SelectedWorkspacePreset.Id,
ComparisonMode = WorkspaceComparisonMode.Id,
+ LapMapHeight = _settings.RunWorkspace.LapMapHeight,
Panels = AvailableWorkspaceModules.Select(module => module.Snapshot()).ToList()
};
settings.Normalize();
diff --git a/src/Wisp.Core/Runs/LapReviewContacts.cs b/src/Wisp.Core/Runs/LapReviewContacts.cs
index 0b55db5..4997468 100644
--- a/src/Wisp.Core/Runs/LapReviewContacts.cs
+++ b/src/Wisp.Core/Runs/LapReviewContacts.cs
@@ -18,7 +18,7 @@ public sealed record LapReviewContact(int PointIndex, double RunSeconds, double
public static class LapReviewContacts
{
public const string ContactMarkerLabel = "Contact";
- public const string EvidenceNote = "Object contacts are reported by Forza for breakable objects. Possible contacts are estimates that may miss or misidentify contact. Mark contacts yourself where needed.";
+ public const string EvidenceNote = "Object contacts are reported by Forza for breakable objects. Possible contacts are estimates that may miss or misidentify contact.";
private const double Gravity = 9.80665;
private const double WindowSeconds = .2;
private const double CooldownSeconds = 1;
@@ -43,7 +43,7 @@ public static LapReviewContact[] Find(LapReviewLap lap, CancellationToken cancel
if (before.SmashableVelocityLossMetersPerSecond == 0 &&
before.SmashableMassKilograms is >= 0 and <= 10_000_000 &&
state.SmashableVelocityLossMetersPerSecond is > 0 and <= 500 &&
- state.SmashableMassKilograms is > 0 and <= 10_000_000 && Continuous(points[index - 1], points[index]))
+ state.SmashableMassKilograms is > 0 and <= 10_000_000 && Continuous(points[index - 1], points[index], observedObject: true))
result.Add(new(index, points[index].RunSeconds, points[index].DistanceMeters, LapReviewContactKind.SmashableObject));
}
var objectTimes = result.Select(contact => contact.RunSeconds).ToArray();
@@ -82,8 +82,7 @@ public static LapReviewContact[] Find(LapReviewLap lap, CancellationToken cancel
return result.OrderBy(contact => contact.PointIndex).ToArray();
}
- // Existing run markers provide an explicit alternative when telemetry cannot
- // identify a contact. No new saved-run schema or game-memory field is required.
+ // Preserve Contact annotations already present in saved or imported runs.
public static LapReviewContact[] FromMarkers(LapReviewLap lap, IReadOnlyList markers,
CancellationToken cancellationToken = default)
{
@@ -138,15 +137,29 @@ private static double HorizontalAcceleration(LapReviewPoint point)
return Math.Sqrt(lateral * lateral + longitudinal * longitudinal);
}
- private static bool Continuous(LapReviewPoint previous, LapReviewPoint current)
+ private static bool Continuous(LapReviewPoint previous, LapReviewPoint current, bool observedObject = false)
{
- if (current.BreakBefore || !RunAnalysis.AreContinuous(previous.Sample, current.Sample) ||
+ // Observed object impulses may arrive on repeated game/lap clocks. Only
+ // their edge detection accepts an unchanged tick at the same position;
+ // inferred movement still requires advancing, continuous time.
+ if (current.BreakBefore ||
+ !(RunAnalysis.AreContinuous(previous.Sample, current.Sample) || observedObject && SameTick(previous.Sample, current.Sample)) ||
!double.IsFinite(previous.LapSeconds) || !double.IsFinite(current.LapSeconds) ||
- current.LapSeconds - previous.LapSeconds is <= 0 or > .25) return false;
+ current.LapSeconds - previous.LapSeconds is < 0 or > .25 ||
+ !observedObject && current.LapSeconds == previous.LapSeconds) return false;
var elapsed = current.RunSeconds - previous.RunSeconds;
- if (!double.IsFinite(elapsed) || elapsed <= 0 || elapsed > .25) return false;
+ if (!double.IsFinite(elapsed) || elapsed < 0 || elapsed > .25 || !observedObject && elapsed == 0) return false;
var distance = Vector3.Distance(previous.Position.ToVector(), current.Position.ToVector());
var speed = Math.Max(previous.Sample.State.GroundSpeedMetersPerSecond, current.Sample.State.GroundSpeedMetersPerSecond);
- return float.IsFinite(distance) && distance <= Math.Max(1, speed * elapsed * 1.5 + .5);
+ return float.IsFinite(distance) && distance <= (elapsed == 0 ? .01 : Math.Max(1, speed * elapsed * 1.5 + .5));
}
+
+ private static bool SameTick(RunSample previous, RunSample current) =>
+ previous.IsDriving && current.IsDriving && previous.State.IsRaceOn && current.State.IsRaceOn &&
+ double.IsFinite(previous.ElapsedSeconds) && previous.ElapsedSeconds >= 0 && current.ElapsedSeconds == previous.ElapsedSeconds &&
+ previous.State.GameTimestampMilliseconds == current.State.GameTimestampMilliseconds &&
+ previous.Segment == current.Segment && previous.State.CarOrdinal == current.State.CarOrdinal &&
+ previous.State.Drivetrain == current.State.Drivetrain &&
+ float.IsFinite(previous.State.GroundSpeedMetersPerSecond) && previous.State.GroundSpeedMetersPerSecond >= 0 &&
+ float.IsFinite(current.State.GroundSpeedMetersPerSecond) && current.State.GroundSpeedMetersPerSecond >= 0;
}
diff --git a/tests/Wisp.App.Tests/LapContactShutdownTests.cs b/tests/Wisp.App.Tests/LapContactShutdownTests.cs
deleted file mode 100644
index 62b1265..0000000
--- a/tests/Wisp.App.Tests/LapContactShutdownTests.cs
+++ /dev/null
@@ -1,168 +0,0 @@
-using System.Diagnostics;
-using System.IO;
-using System.Reflection;
-using System.Runtime.ExceptionServices;
-using System.Windows.Threading;
-using Wisp.App;
-using Wisp.App.Runs;
-using Wisp.Core.Runs;
-using Wisp.Telemetry;
-using Wisp.UiReview;
-using Xunit;
-
-namespace Wisp.App.Tests;
-
-public sealed class LapContactShutdownTests
-{
- [Fact]
- public void CloseWaitsForPendingContactAndLatestNotesBeforeApprovingExit() => OnDispatcher(async () =>
- {
- await using var fixture = await Fixture.Create();
- var model = fixture.Model;
- var review = model.LapReview;
- review.Cursor = 80;
- var seconds = review.Lap!.Points[review.Cursor].RunSeconds;
- model.Notes = "Keep these notes with the contact.";
- await fixture.Gate.WaitAsync();
- Task close;
- try
- {
- review.MarkContactCommand.Execute(null);
- close = model.PrepareToCloseMetadataAsync();
- Assert.Same(close, model.PrepareToCloseMetadataAsync());
- Assert.False(review.MarkContactCommand.CanExecute(null));
- Assert.False(review.RemoveContactCommand.CanExecute(null));
- await Task.Delay(30);
- Assert.False(close.IsCompleted);
- }
- finally { fixture.Gate.Release(); }
- Assert.True(await close);
- var saved = await fixture.Service.Store.LoadAsync(fixture.Run.Id);
- Assert.Equal("Keep these notes with the contact.", saved.Notes);
- Assert.Equal(new RunMarker(seconds, "Contact"), Assert.Single(saved.Markers));
- Assert.Single(model.PlotMarkers);
- Assert.False(review.MarkContactCommand.CanExecute(null));
- model.CancelMetadataClosePreparation();
- Assert.True(review.MarkContactCommand.CanExecute(null));
- Assert.True(review.RemoveContactCommand.CanExecute(null));
- });
-
- [Fact]
- public void SaveFailureDuringCloseKeepsWispOpenAndContactCanBeRetried() => OnDispatcher(async () =>
- {
- await using var fixture = await Fixture.Create();
- var model = fixture.Model;
- var review = model.LapReview;
- review.Cursor = 80;
- Task close;
- using (var locked = new FileStream(fixture.RunPath, FileMode.Open, FileAccess.Read, FileShare.None))
- {
- await fixture.Gate.WaitAsync();
- try
- {
- review.MarkContactCommand.Execute(null);
- close = model.PrepareToCloseMetadataAsync();
- await Task.Delay(30);
- Assert.False(close.IsCompleted);
- }
- finally { fixture.Gate.Release(); }
- Assert.False(await close);
- }
- Assert.Empty((await fixture.Service.Store.LoadAsync(fixture.Run.Id)).Markers);
- await WaitUntil(() => review.MarkContactCommand.CanExecute(null));
- Assert.True(model.CanManageLibrary);
- review.MarkContactCommand.Execute(null);
- Assert.True(await model.PrepareToCloseMetadataAsync());
- Assert.Single((await fixture.Service.Store.LoadAsync(fixture.Run.Id)).Markers);
- });
-
- [Fact]
- public void CancellingCloseDoesNotCancelTheContactOrApproveTheOldCloseAttempt() => OnDispatcher(async () =>
- {
- await using var fixture = await Fixture.Create();
- var model = fixture.Model;
- var review = model.LapReview;
- review.Cursor = 80;
- await fixture.Gate.WaitAsync();
- Task close;
- try
- {
- review.MarkContactCommand.Execute(null);
- close = model.PrepareToCloseMetadataAsync();
- model.CancelMetadataClosePreparation();
- await Task.Delay(30);
- Assert.False(close.IsCompleted);
- Assert.False(review.MarkContactCommand.CanExecute(null));
- }
- finally { fixture.Gate.Release(); }
- Assert.False(await close);
- Assert.Single((await fixture.Service.Store.LoadAsync(fixture.Run.Id)).Markers);
- await WaitUntil(() => review.MarkContactCommand.CanExecute(null));
- Assert.True(await model.PrepareToCloseMetadataAsync());
- });
-
- private sealed class Fixture : IAsyncDisposable
- {
- private readonly string _directory = Path.Combine(Path.GetTempPath(), "Wisp.LapContactShutdownTests", Guid.NewGuid().ToString("N"));
- private readonly TelemetryUdpReceiver _receiver = new();
- internal RecordedRun Run { get; } = LapReviewFixtures.Create();
- internal RunRecordingService Service { get; }
- internal RunsViewModel Model { get; }
- internal string RunPath => Path.Combine(_directory, $"{Run.Id:N}.wisprun");
- internal SemaphoreSlim Gate => (SemaphoreSlim)typeof(RunStore).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(Service.Store)!;
-
- private Fixture()
- {
- Service = new RunRecordingService(_receiver, _directory);
- Model = new RunsViewModel(Service, new AppSettings(), Dispatcher.CurrentDispatcher);
- }
- internal static async Task Create()
- {
- var fixture = new Fixture();
- await fixture.Service.Store.SaveAsync(fixture.Run);
- await fixture.Model.ShowReviewAsync(fixture.Run);
- await WaitUntil(() => !fixture.Model.IsBusy && !fixture.Model.IsPreparingCharts && !fixture.Model.LapReview.IsBusy);
- Assert.False(fixture.Model.HasError);
- Assert.True(fixture.Model.LapReview.HasLap);
- // Allow the real store's metadata restore queued by ShowReview to finish.
- await fixture.Service.Store.LoadAsync(fixture.Run.Id);
- return fixture;
- }
- public async ValueTask DisposeAsync()
- {
- try { await Model.LapReview.PrepareToCloseContactsAsync(); }
- finally
- {
- Model.Dispose(); await Service.DisposeAsync(); await _receiver.DisposeAsync();
- if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
- }
- }
- }
-
- private static async Task WaitUntil(Func condition)
- {
- var elapsed = Stopwatch.StartNew();
- while (!condition() && elapsed.Elapsed < TimeSpan.FromSeconds(5)) await Task.Delay(10);
- Assert.True(condition(), "The bounded contact operation did not settle.");
- }
- private static void OnDispatcher(Func test)
- {
- Exception? failure = null;
- using var finished = new ManualResetEventSlim();
- var thread = new Thread(() =>
- {
- var dispatcher = Dispatcher.CurrentDispatcher;
- _ = dispatcher.InvokeAsync(async () =>
- {
- try { await test(); }
- catch (Exception error) { failure = error; }
- finally { dispatcher.BeginInvokeShutdown(DispatcherPriority.Send); finished.Set(); }
- });
- Dispatcher.Run();
- })
- { IsBackground = true };
- thread.SetApartmentState(ApartmentState.STA); thread.Start();
- Assert.True(finished.Wait(TimeSpan.FromSeconds(20)), "Contact shutdown check exceeded its dispatcher deadline.");
- if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw();
- }
-}
diff --git a/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs b/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs
index 7d93662..f7e52da 100644
--- a/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewMapChannelsTests.cs
@@ -15,7 +15,10 @@ public void AddedChannelsReadActualPowerTorqueAndElevation()
Assert.Equal(100, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Power }, 0)!.Value, 4);
Assert.Equal(250, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Torque }, 0));
Assert.Equal(184.390537325, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Torque, TorqueUnit = TorqueUnit.PoundFeet }, 0)!.Value, 6);
- Assert.Equal(123.5, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Elevation }, 0));
+ Assert.Equal(123.5 / .3048, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Elevation }, 0));
+ Assert.Equal("ft", LapReviewPlot.Unit(data with { Channel = LapReviewChannel.Elevation }));
+ Assert.Equal(123.5, LapReviewPlot.Value(point, data with { Channel = LapReviewChannel.Elevation, SpeedUnit = SpeedUnit.KilometersPerHour }, 0));
+ Assert.Equal("m", LapReviewPlot.Unit(data with { Channel = LapReviewChannel.Elevation, SpeedUnit = SpeedUnit.KilometersPerHour }));
}
[Fact]
diff --git a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
index 6bd5901..f40d0b4 100644
--- a/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewMapComparisonTests.cs
@@ -128,10 +128,8 @@ public void ReferenceCursorAndSectionUseSpatialMatchesAndPickingBMovesTheSharedC
model.Cursor = 150;
Assert.Equal(model.Plot.Comparison!.Points[150].ReferencePointIndex, model.MapPlotB.Cursor);
Assert.InRange(model.MapPlotB.Cursor, 0, model.Reference!.Points.Length - 1);
- var reverse = model.MapPlotB.Comparison!.Points[220].ReferencePointIndex;
- Assert.NotNull(reverse);
model.PickReferencePoint(220);
- Assert.Equal(reverse.Value, model.Cursor);
+ Assert.Equal(220, model.Cursor);
model.Cursor = 75;
model.SectionStartCommand.Execute(null);
await LapReviewComparisonTestSupport.Ready(model);
@@ -284,6 +282,7 @@ public void IncompatibleRoutesRenderIndependentlyWithoutMatchedReferenceNavigati
Assert.True(fixture.Model.CanUseSharedSpace); Assert.True(fixture.Model.IsMapComparison);
Assert.NotNull(fixture.Model.ComparisonMapPlot);
Assert.False(fixture.Model.Plot.Comparison!.CanCompare);
+ fixture.Model.ScrubB = true;
fixture.Model.Cursor = 120;
fixture.Model.PickReferencePoint(220);
Assert.Equal(120, fixture.Model.Cursor);
@@ -315,6 +314,7 @@ public void DifferentCarsUseActualIndependentCursorsAndPreserveBenchmarkGuards(i
Assert.DoesNotContain("Choose Run B", model.MapComparisonHint);
Assert.Null(model.MapPlotB.Comparison);
+ model.ScrubB = true;
model.Cursor = 120;
model.PickReferencePoint(220);
Assert.Equal(120, model.Cursor); Assert.Equal(220, model.MapPlotB.Cursor);
@@ -322,10 +322,12 @@ public void DifferentCarsUseActualIndependentCursorsAndPreserveBenchmarkGuards(i
var data = model.MapPlotB;
var expected = LapReviewPlot.Value(data.Lap!.Points[220], data, 220);
Assert.Equal($"Speed: {RunPresentation.Number(expected, " " + LapReviewPlot.Unit(data))}", model.MapBCursorValue);
+ model.ScrubBoth = true;
model.Cursor = 160;
- Assert.Equal(220, model.MapPlotB.Cursor);
+ var paired = RelativeReferenceIndex(model, 160);
+ Assert.Equal(paired, model.MapPlotB.Cursor);
model.PickReferencePoint(-1); model.PickReferencePoint(int.MaxValue);
- Assert.Equal(220, model.MapPlotB.Cursor); Assert.Equal(160, model.Cursor);
+ Assert.Equal(paired, model.MapPlotB.Cursor); Assert.Equal(160, model.Cursor);
model.SectionStartCommand.Execute(null);
await LapReviewComparisonTestSupport.Ready(model);
model.Cursor = 250;
@@ -344,7 +346,7 @@ public void DifferentCarsUseActualIndependentCursorsAndPreserveBenchmarkGuards(i
model.SetRuns(run, reference);
await LapReviewComparisonTestSupport.Ready(model);
Assert.Equal(reference.Id, model.MapPlotB.Lap!.RunId);
- Assert.Equal(0, model.MapPlotB.Cursor);
+ Assert.Equal(RelativeReferenceIndex(model, model.Cursor), model.MapPlotB.Cursor);
});
[Fact]
@@ -362,6 +364,7 @@ public void PartialRunBIsAvailableForVisualReviewButNotForTiming() => LapReviewC
Assert.Equal(reference.Id, model.Reference.RunId);
Assert.True(model.IsMapComparison); Assert.NotNull(model.ComparisonMapPlot);
Assert.False(model.Plot.Comparison!.CanCompare);
+ model.ScrubB = true;
model.Cursor = 100;
model.PickReferencePoint(120);
Assert.Equal(100, model.Cursor); Assert.Equal(120, model.MapPlotB.Cursor);
@@ -513,6 +516,246 @@ public void LeavingSharedSpaceOrRemovingRunBRestoresTheNormalASlider() => LapRev
Assert.Equal(0, model.ScrubCursor); Assert.Equal(0, model.MaximumScrubCursor);
});
+ [Fact]
+ public void UnmatchedBothFollowsEveryPrimaryCursorChangeAndModeEntry() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var run = LapReviewFixtures.Create();
+ var reference = WithCar(LapReviewFixtures.Create(reference: true), 4200);
+ model.SetRuns(run, reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Cursor = 150;
+ model.Is3D = true; model.SharedSpace = true;
+ AssertPaired(150);
+
+ // Map/trace picking, graph synchronization and A's keyboard events all set Cursor.
+ foreach (var index in new[] { 151, 400, 0, model.MaximumCursor })
+ {
+ model.Cursor = index;
+ AssertPaired(index);
+ }
+ model.Cursor = 120;
+ model.SectionStartCommand.Execute(null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ AssertPaired(120);
+ model.Cursor = 300;
+ model.SectionEndCommand.Execute(null);
+ await LapReviewComparisonTestSupport.Ready(model);
+ AssertPaired(300);
+ Assert.Equal("No match", model.CursorDetails!.Delta);
+ Assert.Equal("Unavailable", model.Metrics[0].Reference);
+
+ model.SharedSpace = false;
+ model.Cursor = 250;
+ model.SharedSpace = true;
+ AssertPaired(250);
+ model.Is3D = false;
+ model.Cursor = 180;
+ model.Is3D = true;
+ AssertPaired(180);
+
+ reference = reference with { Id = Guid.NewGuid(), Samples = reference.Samples.Take(220).ToArray() };
+ model.SetRuns(run, reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ AssertPaired(180);
+ Assert.False(model.Reference!.IsComplete);
+ Assert.False(model.Plot.Comparison!.CanCompare);
+
+ model.ScrubA = true;
+ var held = model.MapPlotB.Cursor;
+ model.Cursor = 200;
+ Assert.Equal(held, model.MapPlotB.Cursor);
+ model.ScrubBoth = true;
+ AssertPaired(200);
+
+ void AssertPaired(int index)
+ {
+ Assert.True(model.ScrubBoth); Assert.Equal(index, model.Cursor);
+ Assert.Equal(RelativeReferenceIndex(model, index), model.MapPlotB.Cursor);
+ }
+ });
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void MatchedReferenceNavigationAdvancesAcrossUnequalSampleDensityAndFindsEndpoints(bool primaryIsSparse) => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var run = LapReviewFixtures.Create();
+ var reference = LapReviewFixtures.Create(reference: true);
+ if (primaryIsSparse) run = run with { Samples = run.Samples.Where((_, index) => index % 2 == 0).ToArray() };
+ else reference = reference with { Samples = reference.Samples.Where((_, index) => index % 2 == 0).ToArray() };
+ model.SetRuns(run, reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ Assert.True(model.Plot.Comparison!.CanCompare);
+ Assert.NotEqual(model.Reference!.Points.Length, model.Lap!.Points.Length);
+ model.Cursor = 100;
+ var before = model.Cursor;
+ var referenceBefore = model.Plot.Comparison.Points[before].ReferenceLapSeconds!.Value;
+ for (var count = 0; count < 5; count++)
+ {
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.True(model.Cursor > before);
+ var seconds = model.Plot.Comparison.Points[model.Cursor].ReferenceLapSeconds!.Value;
+ Assert.True(seconds > referenceBefore);
+ before = model.Cursor; referenceBefore = seconds;
+ }
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Previous);
+ Assert.True(model.Cursor < before);
+ Assert.True(model.Plot.Comparison.Points[model.Cursor].ReferenceLapSeconds < referenceBefore);
+
+ var expectedStart = Enumerable.Range(0, model.Lap.Points.Length)
+ .First(index => LapReviewPlot.ReferencePosition(model.Plot, index) is not null);
+ var expectedEnd = Enumerable.Range(0, model.Lap.Points.Length)
+ .Last(index => LapReviewPlot.ReferencePosition(model.Plot, index) is not null);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Start);
+ Assert.Equal(expectedStart, model.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Previous);
+ Assert.Equal(expectedStart, model.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.End);
+ Assert.Equal(expectedEnd, model.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.Equal(expectedEnd, model.Cursor);
+
+ model.ScrubB = true;
+ var primary = model.Cursor;
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Start);
+ Assert.Equal(0, model.MapPlotB.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.Equal(1, model.MapPlotB.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.End);
+ Assert.Equal(model.Reference.Points.Length - 1, model.MapPlotB.Cursor);
+ Assert.Equal(primary, model.Cursor);
+ });
+
+ [Fact]
+ public void MatchedReferenceClickChoosesTheNearestRecordedPrimarySampleInsteadOfAlwaysTheEarlierOne() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var run = LapReviewFixtures.Create();
+ run = run with { Samples = run.Samples.Where((_, index) => index % 2 == 0).ToArray() };
+ model.SetRuns(run, LapReviewFixtures.Create(reference: true));
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ // This B sample is exactly on A's next sample, but the match stores the preceding edge.
+ var reverse = model.MapPlotB.Comparison!;
+ var chosen = Enumerable.Range(1, model.Reference!.Points.Length - 2).First(index =>
+ {
+ var match = reverse.Points[index];
+ if (match.ReferencePointIndex is not { } lower || match.ReferenceLapSeconds is not { } seconds ||
+ lower + 1 >= model.Lap!.Points.Length) return false;
+ return Math.Abs(model.Lap.Points[lower + 1].LapSeconds - seconds) < Math.Abs(seconds - model.Lap.Points[lower].LapSeconds) &&
+ LapReviewPlot.ReferencePosition(model.Plot, lower + 1) is not null;
+ });
+ var expected = reverse.Points[chosen].ReferencePointIndex!.Value + 1;
+ model.PickReferencePoint(chosen);
+ Assert.Equal(expected, model.Cursor);
+ Assert.NotNull(model.MapPlotB.CursorPositionOverride);
+ Assert.Equal(model.Lap!.Points[expected].LapSeconds.ToString("0.000"), model.CursorDetails!.Position.Split(' ')[0]);
+ });
+
+ [Fact]
+ public void MatchedReferenceNavigationSkipsUnmatchedSamplesWithoutCreatingTimingValues() => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ model.SetRuns(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true));
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ var comparison = model.Plot.Comparison!;
+ foreach (var index in new[] { 0, 101, model.MaximumCursor })
+ comparison.Points[index] = comparison.Points[index] with
+ { ReferencePointIndex = null, ReferenceLapSeconds = null, DeltaSeconds = null };
+ model.Cursor = 100;
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.Equal(102, model.Cursor);
+ Assert.Null(comparison.Points[101].DeltaSeconds);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Start);
+ Assert.Equal(1, model.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.End);
+ Assert.Equal(model.MaximumCursor - 1, model.Cursor);
+ Assert.Null(comparison.Points[0].DeltaSeconds);
+ Assert.Null(comparison.Points[^1].DeltaSeconds);
+ });
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void UnmatchedBothReferenceClicksAndKeysMoveBothWithoutEnablingMatchedTiming(bool primaryIsSparse) => LapReviewComparisonTestSupport.OnDispatcher(async () =>
+ {
+ using var fixture = new LapReviewComparisonTestSupport();
+ var model = fixture.Model;
+ var run = LapReviewFixtures.Create();
+ var reference = WithCar(LapReviewFixtures.Create(reference: true), 4200);
+ if (primaryIsSparse) run = run with { Samples = run.Samples.Where((_, index) => index % 2 == 0).ToArray() };
+ else reference = reference with { Samples = reference.Samples.Where((_, index) => index % 2 == 0).ToArray() };
+ model.SetRuns(run, reference);
+ await LapReviewComparisonTestSupport.Ready(model);
+ model.Is3D = true; model.SharedSpace = true;
+ Assert.True(model.ScrubBoth);
+ var selectedReference = model.Reference!.Points.Length / 3;
+ var expectedPrimary = RelativeIndex(model.Reference, model.Lap!, selectedReference);
+ model.PickReferencePoint(selectedReference);
+ Assert.Equal(expectedPrimary, model.Cursor);
+ AssertPaired();
+ var primary = model.Cursor; var secondary = model.MapPlotB.Cursor;
+ for (var count = 0; count < 5; count++)
+ {
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.True(model.Cursor > primary);
+ Assert.True(model.MapPlotB.Cursor > secondary);
+ primary = model.Cursor; secondary = model.MapPlotB.Cursor;
+ AssertPaired();
+ }
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Previous);
+ Assert.True(model.Cursor < primary); Assert.True(model.MapPlotB.Cursor < secondary);
+ AssertPaired();
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Start);
+ Assert.Equal(0, model.Cursor); Assert.Equal(0, model.MapPlotB.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Previous);
+ Assert.Equal(0, model.Cursor); Assert.Equal(0, model.MapPlotB.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.End);
+ Assert.Equal(model.MaximumCursor, model.Cursor);
+ Assert.Equal(model.Reference.Points.Length - 1, model.MapPlotB.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.Equal(model.MaximumCursor, model.Cursor);
+ Assert.Equal(model.Reference.Points.Length - 1, model.MapPlotB.Cursor);
+
+ model.ScrubB = true;
+ primary = model.Cursor;
+ model.PickReferencePoint(30);
+ Assert.Equal(primary, model.Cursor); Assert.Equal(30, model.MapPlotB.Cursor);
+ model.NavigateReferenceCursor(LapReviewCursorNavigation.Next);
+ Assert.Equal(primary, model.Cursor); Assert.Equal(31, model.MapPlotB.Cursor);
+
+ void AssertPaired()
+ {
+ Assert.Equal(RelativeReferenceIndex(model, model.Cursor), model.MapPlotB.Cursor);
+ Assert.False(model.Plot.Comparison!.CanCompare);
+ Assert.Null(model.MapPlotB.Comparison);
+ Assert.Null(model.MapPlotB.CursorPositionOverride);
+ Assert.Equal(-1, model.MapPlotB.SectionStart); Assert.Equal(-1, model.MapPlotB.SectionEnd);
+ Assert.Equal("No match", model.CursorDetails!.Delta);
+ }
+ });
+
+ private static int RelativeReferenceIndex(LapReviewViewModel model, int index) => RelativeIndex(model.Lap!, model.Reference!, index);
+
+ private static int RelativeIndex(LapReviewLap lap, LapReviewLap reference, int index)
+ {
+ if (index == 0) return 0;
+ if (index == lap.Points.Length - 1) return reference.Points.Length - 1;
+ var progress = (lap.Points[index].DistanceMeters - lap.Points[0].DistanceMeters) /
+ (lap.Points[^1].DistanceMeters - lap.Points[0].DistanceMeters);
+ var distance = reference.Points[0].DistanceMeters + progress *
+ (reference.Points[^1].DistanceMeters - reference.Points[0].DistanceMeters);
+ return Enumerable.Range(0, reference.Points.Length).MinBy(candidate => Math.Abs(reference.Points[candidate].DistanceMeters - distance));
+ }
+
private static RecordedRun WithCar(RecordedRun run, int carOrdinal) => run with
{
Samples = run.Samples.Select(sample => sample with { State = sample.State with { CarOrdinal = carOrdinal } }).ToArray()
diff --git a/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs b/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs
index ee86143..9151259 100644
--- a/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewPlotRenderingTests.cs
@@ -13,6 +13,29 @@ namespace Wisp.App.Tests;
public sealed class LapReviewPlotRenderingTests
{
+ [Fact]
+ public void EstimatedContactHasAHollowCenterWhileReportedContactIsFilled() => OnSta(() =>
+ {
+ byte[] Draw(LapReviewContactKind kind)
+ {
+ var visual = new DrawingVisual();
+ using (var drawing = visual.RenderOpen())
+ {
+ drawing.DrawRectangle(Brushes.Black, null, new Rect(0, 0, 32, 32));
+ LapReviewPalette.DrawContact(drawing, new Point(16, 16), Brushes.Black, kind: kind);
+ }
+ var bitmap = new RenderTargetBitmap(32, 32, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+ var pixel = new byte[4];
+ bitmap.CopyPixels(new Int32Rect(16, 16, 1, 1), pixel, 4, 0);
+ return pixel;
+ }
+ var estimated = Draw(LapReviewContactKind.PossibleContact);
+ var recorded = Draw(LapReviewContactKind.SmashableObject);
+ Assert.Equal(new byte[] { 0, 0, 0, 255 }, estimated);
+ Assert.True(recorded[2] > 200 && recorded[1] > 60);
+ });
+
[Theory]
[InlineData(false)]
[InlineData(true)]
diff --git a/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs b/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
index 37a0bab..559e467 100644
--- a/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewScrubbingTests.cs
@@ -14,10 +14,35 @@ internal static void AssertOnCurrentDispatcher()
{
var previousContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher.CurrentDispatcher));
- try { AssertScrubbing(); }
+ try { AssertScrubbing(); AssertSavedMapHeight(); }
finally { SynchronizationContext.SetSynchronizationContext(previousContext); }
}
+ private static void AssertSavedMapHeight()
+ {
+ var settings = new AppSettings();
+ using var first = new LapReviewComparisonTestSupport(settings);
+ first.Model.MapHeight = 577;
+ var view = new LapReviewView { DataContext = first.Model };
+ view.Measure(new Size(1000, 800)); view.Arrange(new Rect(0, 0, 1000, 800));
+ Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
+ view.GetBindingExpression(LapReviewView.RequestedMapHeightProperty)!.UpdateTarget();
+ Assert.Equal(577, view.RequestedMapHeight);
+ view.DataContext = null;
+ Assert.Equal(577, settings.RunWorkspace.LapMapHeight);
+ using var second = new LapReviewComparisonTestSupport(settings);
+ Assert.Equal(577, second.Model.MapHeight);
+ var reopened = new LapReviewView { DataContext = second.Model };
+ reopened.Measure(new Size(1000, 800)); reopened.Arrange(new Rect(0, 0, 1000, 800));
+ Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
+ reopened.GetBindingExpression(LapReviewView.RequestedMapHeightProperty)!.UpdateTarget();
+ Assert.Equal(577, reopened.RequestedMapHeight);
+ reopened.SetCurrentValue(LapReviewView.RequestedMapHeightProperty, double.NaN);
+ reopened.GetBindingExpression(LapReviewView.RequestedMapHeightProperty)!.UpdateSource();
+ Assert.Equal(0, settings.RunWorkspace.LapMapHeight);
+ reopened.DataContext = null;
+ }
+
private static void AssertScrubbing()
{
using var fixture = new LapReviewComparisonTestSupport();
diff --git a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
index 350cd2b..6d3abde 100644
--- a/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewTrack3DTests.cs
@@ -184,7 +184,7 @@ public void ContactAnnotationStaysAboveAnOccludingTrackAndFollowsTheCameraWithou
// The raised return segment passes directly above the contact in a top view.
// Coloring by elevation proves the red foreground track really covers that position.
var data = Data([new(-100, 0, 0), new(0, 0, 0), new(100, 0, 0), new(100, 60, 0), new(-100, 60, 0)]) with
- { Channel = LapReviewChannel.Elevation, Contacts = [new(1, 1, 100, LapReviewContactKind.PossibleContact)] };
+ { Channel = LapReviewChannel.Elevation, Contacts = [new(1, 1, 100, LapReviewContactKind.SmashableObject)] };
var track = new LapReviewTrack3D { Data = data, ShowContacts = false };
var surface = new Border { Background = Brushes.Black, Child = track };
surface.Measure(new Size(800, 400)); surface.Arrange(new Rect(0, 0, 800, 400));
@@ -227,7 +227,7 @@ private static void AssertPixel(FrameworkElement surface, Point anchor, Color ex
public void InterpolatedCursorMovesWithoutMovingContactsOrRebuildingGeometry() => OnSta(async () =>
{
var data = Data([new(-100, 0, 0), new(0, 0, 0), new(100, 0, 0)]) with
- { Cursor = 1, Contacts = [new(1, 1, 100, LapReviewContactKind.PossibleContact)] };
+ { Cursor = 1, Contacts = [new(1, 1, 100, LapReviewContactKind.SmashableObject)] };
var track = new LapReviewTrack3D { Data = data };
var surface = new Border { Background = Brushes.Black, Child = track };
surface.Measure(new Size(800, 400)); surface.Arrange(new Rect(0, 0, 800, 400));
@@ -266,6 +266,91 @@ public void ComparisonModelsShareOneSceneAndClickingEitherTrackFocusesWithoutRem
Assert.True(track.HasBothPreparedModels); Assert.Equal(2, track.SceneBuildCount);
});
+ [Fact]
+ public void PickingAnOverpassSelectsTheVisibleTrackUnlessItsTopLayerAnnotationWasClicked() => OnSta(async () =>
+ {
+ var data = Data([new(-100, 0, 0), new(0, 0, 0), new(100, 0, 0),
+ new(-100, 30, 0), new(0, 30, 0), new(100, 30, 0)]);
+ data.Lap!.Points[3] = data.Lap.Points[3] with { BreakBefore = true };
+ var track = new LapReviewTrack3D { Data = data };
+ track.Measure(new Size(800, 400)); track.Arrange(new Rect(0, 0, 800, 400));
+ await track.PrepareForTestAsync();
+ var chosen = -1;
+ track.PointChosen += index => chosen = index;
+ track.Yaw = 0; track.Pitch = 90;
+ track.Choose(track.ProjectPoint(1));
+ Assert.Equal(4, chosen);
+ track.Pitch = -90;
+ track.Choose(track.ProjectPoint(4));
+ Assert.Equal(1, chosen);
+ track.Pitch = 90;
+ track.Data = data with { Contacts = [new(1, 1, 100, LapReviewContactKind.SmashableObject)] };
+ track.Choose(track.ProjectPoint(1));
+ Assert.Equal(1, chosen);
+ track.ShowContacts = false;
+ track.Choose(track.ProjectPoint(1));
+ Assert.Equal(4, chosen);
+ Assert.Equal(1, track.SceneBuildCount);
+ });
+
+ [Fact]
+ public void DistantBackgroundClicksDoNotSelectPointsOrStartCameraMotion() => OnSta(async () =>
+ {
+ var track = ComparisonTrack(); await track.PrepareForTestAsync();
+ var selected = 0;
+ track.PointChosen += _ => selected++;
+ track.ReferencePointChosen += _ => selected++;
+ track.Choose(new Point(0, 0));
+ Assert.Equal(0, selected);
+ Assert.Equal(0, track.FocusedLap);
+ Assert.False(track.IsCameraMotionActive);
+ });
+
+ [Fact]
+ public void SharedReferenceKeyboardNavigationPreservesDirectionForTheViewModel() => OnSta(async () =>
+ {
+ var track = ComparisonTrack(); await track.PrepareForTestAsync();
+ track.FocusLap(2); track.CompleteCameraMotionForTest();
+ var navigations = new List();
+ var pointChoices = new List();
+ track.ReferenceCursorNavigationRequested += navigations.Add;
+ track.ReferencePointChosen += pointChoices.Add;
+ foreach (var key in new[] { Key.Right, Key.Up, Key.Left, Key.Down, Key.Home, Key.End })
+ Assert.True(SendKey(track, key).Handled);
+ Assert.Equal([LapReviewCursorNavigation.Next, LapReviewCursorNavigation.Next,
+ LapReviewCursorNavigation.Previous, LapReviewCursorNavigation.Previous,
+ LapReviewCursorNavigation.Start, LapReviewCursorNavigation.End], navigations);
+ Assert.Empty(pointChoices);
+ });
+
+ [Fact]
+ public void ClickingTheInterpolatedReferenceCursorFocusesWithoutReselectingItsLowerSample() => OnSta(async () =>
+ {
+ var track = ComparisonTrack();
+ var reference = track.ComparisonData!;
+ var a = reference.Lap!.Points[1].Position;
+ var b = reference.Lap.Points[2].Position;
+ track.Data = track.Data! with { Cursor = 2 };
+ track.ComparisonData = reference with
+ {
+ Cursor = 1,
+ CursorPositionOverride = new((a.X + b.X) / 2, (a.Y + b.Y) / 2, (a.Z + b.Z) / 2)
+ };
+ await track.PrepareForTestAsync();
+ var selections = 0;
+ track.PointChosen += _ => selections++;
+ track.ReferencePointChosen += _ => selections++;
+ var first = track.ProjectReferencePoint(1);
+ var cursor = first + (track.ProjectReferencePoint(2) - first) * .5;
+ track.Choose(cursor);
+ Assert.Equal(0, selections);
+ Assert.Equal(2, track.Data.Cursor);
+ Assert.Equal(1, track.ComparisonData.Cursor);
+ Assert.Equal(2, track.FocusedLap);
+ track.CompleteCameraMotionForTest();
+ Assert.Equal(2, track.SceneBuildCount);
+ });
+
[Fact]
public void DistinctLapsRemainVisibleWhenBenchmarkComparisonIsUnavailable() => OnSta(async () =>
{
@@ -491,19 +576,33 @@ public void SelectingAnotherPointOnTheFocusedLapPreservesManualCameraAdjustments
Assert.True(track.HasBothPreparedModels); Assert.Equal(2, track.SceneBuildCount);
});
- [Fact]
- public void RemovingComparisonAndUnloadingCancelCameraMotionAndKeepPrimaryGeometryReusable() => OnSta(async () =>
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void RemovingComparisonRestoresSingleMapLayoutAndReusesOnlyCompatibleGeometry(bool sameBounds) => OnSta(async () =>
{
- var track = ComparisonTrack(); var other = track.ComparisonData;
+ var track = ComparisonTrack();
+ if (sameBounds)
+ {
+ track.ComparisonData = track.ComparisonData! with
+ { Lap = track.ComparisonData!.Lap! with { Points = track.Data!.Lap!.Points } };
+ track.Data = track.Data! with { Reference = track.ComparisonData!.Lap };
+ }
+ var other = track.ComparisonData;
+ var normal = new LapReviewTrack3D { Data = track.Data };
+ normal.Measure(track.RenderSize); normal.Arrange(new Rect(track.RenderSize));
+ await normal.PrepareForTestAsync();
await track.PrepareForTestAsync();
track.FocusLap(2); Assert.True(track.IsCameraMotionActive);
track.RaiseEvent(new RoutedEventArgs(FrameworkElement.UnloadedEvent));
Assert.False(track.IsCameraMotionActive);
track.ComparisonData = null; await track.PrepareForTestAsync();
Assert.False(track.IsComparison); Assert.False(track.HasBothPreparedModels);
- Assert.True(track.IsReady); Assert.Equal(2, track.SceneBuildCount);
+ Assert.True(track.IsReady); Assert.Equal(sameBounds ? 2 : 3, track.SceneBuildCount);
+ for (var index = 0; index < track.Data!.Lap!.Points.Length; index++)
+ Assert.Equal(normal.ProjectPoint(index), track.ProjectPoint(index));
track.ComparisonData = other; await track.PrepareForTestAsync();
- Assert.True(track.IsComparison); Assert.True(track.HasBothPreparedModels); Assert.Equal(3, track.SceneBuildCount);
+ Assert.True(track.IsComparison); Assert.True(track.HasBothPreparedModels); Assert.Equal(sameBounds ? 3 : 5, track.SceneBuildCount);
track.FocusLap(1); track.Data = null;
Assert.False(track.IsCameraMotionActive); Assert.False(track.IsReady);
});
diff --git a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
index fb73388..80f7d02 100644
--- a/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
+++ b/tests/Wisp.App.Tests/LapReviewTrackGeometryTests.cs
@@ -69,6 +69,46 @@ public void SideBySidePlacementPreservesEqualScaleAndTrueElevationInOneCoordinat
Assert.Equal(8, placement.PrimaryCorners.Length); Assert.Equal(8, placement.ReferenceCorners.Length);
}
+ [Fact]
+ public void SharedSpaceUsesTrackDimensionsRatherThanDistanceBetweenGameLocations()
+ {
+ var first = Data([new(-100, 20, 0), new(0, 35, 100), new(100, 25, 0)]);
+ var second = Data([new(-100, 40, 0), new(0, 80, 150), new(200, 50, 0)]);
+ var distant = second with
+ {
+ Lap = second.Lap! with
+ {
+ Points = second.Lap.Points.Select(point => point with
+ { Position = point.Position with { X = point.Position.X + 10000, Z = point.Position.Z - 20000 } }).ToArray()
+ }
+ };
+ var nearbyBounds = LapReviewTrackBounds.ForComparison(first, second);
+ var distantBounds = LapReviewTrackBounds.ForComparison(first, distant);
+ Assert.Equal(nearbyBounds.Primary, distantBounds.Primary);
+ Assert.Equal(300, distantBounds.Primary.Span);
+ Assert.Equal(distantBounds.Primary.Span, distantBounds.Reference.Span);
+ Assert.Equal(nearbyBounds.Reference.MinimumHeight, distantBounds.Reference.MinimumHeight);
+ Assert.Equal(nearbyBounds.Reference.Center.Y, distantBounds.Reference.Center.Y);
+ for (var i = 0; i < second.Lap!.Points.Length; i++)
+ Assert.Equal(nearbyBounds.Reference.Normalize(second.Lap.Points[i].Position),
+ distantBounds.Reference.Normalize(distant.Lap!.Points[i].Position));
+
+ var nearby = LapReviewTrackArrangement.Create(first, second, nearbyBounds.Primary, nearbyBounds.Reference);
+ var remote = LapReviewTrackArrangement.Create(first, distant, distantBounds.Primary, distantBounds.Reference);
+ Assert.Equal(nearby.PrimaryOffset, remote.PrimaryOffset);
+ Assert.Equal(nearby.ReferenceOffset, remote.ReferenceOffset);
+ foreach (var angle in new[] { (0d, 22d, 0d), (140d, 70d, 35d) })
+ Assert.Equal(nearby.ProjectedFit(angle.Item1, angle.Item2, angle.Item3), remote.ProjectedFit(angle.Item1, angle.Item2, angle.Item3));
+
+ var normalizedA = distantBounds.Primary.Normalize(first.Lap!.Points[0].Position);
+ var normalizedB = distantBounds.Reference.Normalize(distant.Lap!.Points[0].Position);
+ Assert.Equal(20, (normalizedB.Y - normalizedA.Y) * distantBounds.Primary.Span, 8);
+ var nearbyScene = LapReviewTrackGeometry.Build(second, nearbyBounds.Reference, TestContext.Current.CancellationToken);
+ var remoteScene = LapReviewTrackGeometry.Build(distant, distantBounds.Reference, TestContext.Current.CancellationToken);
+ Assert.Equal(nearbyScene.Model.Children.Cast().SelectMany(model => ((MeshGeometry3D)model.Geometry).Positions),
+ remoteScene.Model.Children.Cast().SelectMany(model => ((MeshGeometry3D)model.Geometry).Positions));
+ }
+
[Fact]
public void NonFinitePositionsDoNotPoisonBoundsOrCreateABridge()
{
diff --git a/tests/Wisp.App.Tests/RunRecordingOptionsTests.cs b/tests/Wisp.App.Tests/RunRecordingOptionsTests.cs
index 7e75e68..28478fb 100644
--- a/tests/Wisp.App.Tests/RunRecordingOptionsTests.cs
+++ b/tests/Wisp.App.Tests/RunRecordingOptionsTests.cs
@@ -143,6 +143,8 @@ public async Task FreshMatchingTuneStartsAndPersistsTheReviewedImmutableSnapshot
Assert.NotEqual(laterSelection.Snapshot.Id, run.TuneAttachment.Snapshot.Id);
Assert.Equal(snapshotJson, JsonSerializer.Serialize(run.TuneAttachment.Snapshot, RunStore.JsonOptions));
var loaded = await fixture.Service.Store.LoadAsync(run.Id);
+ Assert.Equal(RecordedRun.TuneAttachmentSchemaVersion, loaded.SchemaVersion);
+ Assert.All(loaded.Samples, sample => Assert.Null(sample.State.SmashableMassKilograms));
Assert.Equal(snapshotJson, JsonSerializer.Serialize(loaded.TuneAttachment!.Snapshot, RunStore.JsonOptions));
Assert.All(loaded.Samples, sample => Assert.Equal(snapshot.Identity.CarOrdinal, sample.State.CarOrdinal));
Assert.True(fixture.Receiver.IsRunning);
diff --git a/tests/Wisp.App.Tests/RunStoreContactsTests.cs b/tests/Wisp.App.Tests/RunStoreContactsTests.cs
index 6177216..92aa6e9 100644
--- a/tests/Wisp.App.Tests/RunStoreContactsTests.cs
+++ b/tests/Wisp.App.Tests/RunStoreContactsTests.cs
@@ -1,4 +1,5 @@
using System.IO;
+using System.IO.Compression;
using System.Text.Json;
using Wisp.App.Runs;
using Wisp.Core;
@@ -11,83 +12,6 @@ public sealed class RunStoreContactsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "WispRunContactTests", Guid.NewGuid().ToString("N"));
- [Fact]
- public async Task AddAndRemoveContactPreserveLatestMetadataAndEverySample()
- {
- var store = new RunStore(_directory);
- var run = RunTestData.CreateRun() with { Markers = [new(0, "Apex")] };
- await store.SaveAsync(run);
- await store.UpdateMetadataAsync(run.Id, "Edited after opening lap", "Current tune", "New notes");
- var time = run.Samples[^1].ElapsedSeconds;
- var markers = await store.SetContactMarkerAsync(run.Id, time, true);
- Assert.Equal(2, markers.Length);
- var loaded = await store.LoadAsync(run.Id);
- Assert.Equal("Edited after opening lap", loaded.Name);
- Assert.Equal("Current tune", loaded.Tune);
- Assert.Equal("New notes", loaded.Notes);
- Assert.Equal(run.Samples, loaded.Samples);
- Assert.Equal(markers, loaded.Markers);
- Assert.Equal(run.LapTimingMode, loaded.LapTimingMode);
- Assert.Equal(run.SchemaVersion, loaded.SchemaVersion);
- Assert.Equal(run.StartedAtUtc, loaded.StartedAtUtc);
- Assert.Equal(2, (await store.SetContactMarkerAsync(run.Id, time, true)).Length);
- Assert.Equal([new RunMarker(0, "Apex")], await store.SetContactMarkerAsync(run.Id, time, false));
- Assert.Equal(run.Samples, (await store.LoadAsync(run.Id)).Samples);
- }
-
- [Fact]
- public async Task ContactRemovalKeepsOtherMarkersAtTheSameTime()
- {
- var store = new RunStore(_directory);
- var run = RunTestData.CreateRun() with { Markers = [new(0, "Contact"), new(0, "Apex"), new(.1, "Contact")] };
- await store.SaveAsync(run);
- Assert.Equal([new RunMarker(0, "Apex"), new(.1, "Contact")], await store.SetContactMarkerAsync(run.Id, 0, false));
- }
-
- [Theory]
- [InlineData(double.NaN)]
- [InlineData(double.PositiveInfinity)]
- [InlineData(-.1)]
- [InlineData(100000)]
- public async Task InvalidTimeCannotModifySavedRun(double time)
- {
- var store = new RunStore(_directory);
- var run = RunTestData.CreateRun();
- await store.SaveAsync(run);
- await Assert.ThrowsAsync(() => store.SetContactMarkerAsync(run.Id, time, true));
- Assert.Equal(run.Markers, (await store.LoadAsync(run.Id)).Markers);
- }
-
- [Fact]
- public async Task MarkerLimitDoesNotReplaceOrLoseExistingMarkers()
- {
- var store = new RunStore(_directory);
- var run = RunTestData.CreateRun() with
- {
- Markers = Enumerable.Range(0, RunStore.MaximumMarkers).Select(index => new RunMarker(0, $"Note {index}")).ToArray()
- };
- await store.SaveAsync(run);
- await Assert.ThrowsAsync(() => store.SetContactMarkerAsync(run.Id, 0, true));
- Assert.Equal(run.Markers, (await store.LoadAsync(run.Id)).Markers);
- Assert.Equal(run.Markers, await store.SetContactMarkerAsync(run.Id, 0, false));
- }
-
- [Fact]
- public async Task ConcurrentEditsReadLatestRunBeforeChangingOnlyTheirOwnedFields()
- {
- var store = new RunStore(_directory);
- var run = RunTestData.CreateRun();
- await store.SaveAsync(run);
- await Task.WhenAll(store.UpdateMetadataAsync(run.Id, "Renamed", "Retuned", "Updated"),
- store.SetContactMarkerAsync(run.Id, 0, true), store.SetContactMarkerAsync(run.Id, run.Samples[^1].ElapsedSeconds, true));
- var loaded = await store.LoadAsync(run.Id);
- Assert.Equal("Renamed", loaded.Name);
- Assert.Equal("Retuned", loaded.Tune);
- Assert.Equal("Updated", loaded.Notes);
- Assert.Equal(2, loaded.Markers.Count(marker => marker.Label == "Contact"));
- Assert.Equal(run.Samples, loaded.Samples);
- }
-
[Fact]
public async Task OptionalObjectImpactFieldsRoundTripAndLegacyRunsKeepUnknownRatherThanInventedZero()
{
@@ -133,14 +57,14 @@ public async Task ContactTelemetryUpgradesStructuredTuneFormatWithoutChangingSna
{
SchemaVersion = RecordedRun.TuneAttachmentSchemaVersion,
TuneAttachment = new(snapshot, "Saved setup", "Tune snapshot", DateTimeOffset.UtcNow, RunTuneAttachmentKind.CurrentAtStart),
- Samples = original.Samples.Select(sample => sample with
+ Samples = original.Samples.Select((sample, index) => sample with
{
State = sample.State with
{
CarOrdinal = snapshot.Identity.CarOrdinal,
Drivetrain = (DrivetrainType)snapshot.Identity.Drivetrain,
- SmashableVelocityLossMetersPerSecond = 0,
- SmashableMassKilograms = 0
+ SmashableVelocityLossMetersPerSecond = index == 1 ? 2 : 0,
+ SmashableMassKilograms = index == 1 ? 150 : 0
}
}).ToArray()
};
@@ -148,12 +72,112 @@ public async Task ContactTelemetryUpgradesStructuredTuneFormatWithoutChangingSna
await store.SaveAsync(run);
var loaded = await store.LoadAsync(run.Id);
Assert.Equal(RecordedRun.CurrentSchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(run.Samples, loaded.Samples);
Assert.Equal(JsonSerializer.Serialize(run.TuneAttachment, RunStore.JsonOptions),
JsonSerializer.Serialize(loaded.TuneAttachment, RunStore.JsonOptions));
- await store.SetContactMarkerAsync(run.Id, 0, true);
+ await store.UpdateMetadataAsync(run.Id, run.Name, run.Tune, "Updated notes");
Assert.Equal(RecordedRun.CurrentSchemaVersion, (await store.LoadAsync(run.Id)).SchemaVersion);
}
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task FinalizedRunWithoutPositiveObjectDataUsesLegacyFormat(bool withTune, bool zeroFields)
+ {
+ var run = ContactRun(withTune, zeroFields);
+ var store = new RunStore(_directory);
+ await store.SaveAsync(run);
+ var loaded = await store.LoadAsync(run.Id);
+ var expected = WithoutContactFields(run);
+ Assert.Equal(JsonSerializer.Serialize(expected, RunStore.JsonOptions), JsonSerializer.Serialize(loaded, RunStore.JsonOptions));
+ Assert.All(loaded.Samples, sample =>
+ Assert.DoesNotContain("smashable", JsonSerializer.Serialize(sample.State, RunStore.JsonOptions), StringComparison.OrdinalIgnoreCase));
+ if (zeroFields) Assert.All(run.Samples, sample => Assert.Equal(0f, sample.State.SmashableMassKilograms));
+ }
+
+ [Theory]
+ [InlineData(2f, 0f)]
+ [InlineData(0f, 150f)]
+ [InlineData(2f, 150f)]
+ public async Task AnyPositiveObjectFieldRetainsV3AndEveryZeroBaseline(float loss, float mass)
+ {
+ var run = ContactRun(withTune: false, zeroFields: true);
+ run = run with
+ {
+ Samples = [run.Samples[0], run.Samples[1] with
+ {
+ State = run.Samples[1].State with { SmashableVelocityLossMetersPerSecond = loss, SmashableMassKilograms = mass }
+ }, run.Samples[1] with { ElapsedSeconds = .2, State = run.Samples[1].State with { GameTimestampMilliseconds = 200 } }]
+ };
+ var store = new RunStore(_directory);
+ await store.SaveAsync(run);
+ var loaded = await store.LoadAsync(run.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(run.Samples, loaded.Samples);
+ var archive = Path.Combine(_directory, "contacts.zip");
+ Assert.Equal(1, await store.ExportAllAsync(archive));
+ var importedDirectory = Path.Combine(_directory, "imported");
+ var imported = new RunStore(importedDirectory);
+ await imported.ImportManyAsync([archive]);
+ var importedRun = await imported.LoadAsync(run.Id);
+ Assert.Equal(JsonSerializer.Serialize(loaded, RunStore.JsonOptions), JsonSerializer.Serialize(importedRun, RunStore.JsonOptions));
+ Assert.Equal(await File.ReadAllBytesAsync(Path.Combine(_directory, $"{run.Id:N}.wisprun"), TestContext.Current.CancellationToken),
+ await File.ReadAllBytesAsync(Path.Combine(importedDirectory, $"{run.Id:N}.wisprun"), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ExistingAndImportedZeroOnlyV3RunsRetainTheirFormatAndFields()
+ {
+ var run = ContactRun(withTune: false, zeroFields: true);
+ var path = Path.Combine(_directory, $"{run.Id:N}.wisprun");
+ await WriteRawRunAsync(path, run);
+ var store = new RunStore(_directory);
+ Assert.Single(await store.ListAsync());
+ await store.UpdateMetadataAsync(run.Id, run.Name, run.Tune, "Edited notes");
+ var loaded = await store.LoadAsync(run.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(run.Samples, loaded.Samples);
+ Assert.Equal(run.Markers, loaded.Markers);
+ var singleImport = new RunStore(Path.Combine(_directory, "single"));
+ var summary = await singleImport.ImportAsync(path);
+ var single = await singleImport.LoadAsync(summary.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, single.SchemaVersion);
+ Assert.Equal(run.Samples, single.Samples);
+ var bulkImport = new RunStore(Path.Combine(_directory, "bulk"));
+ await bulkImport.ImportManyAsync([path]);
+ var bulk = await bulkImport.LoadAsync(run.Id);
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, bulk.SchemaVersion);
+ Assert.Equal(run.Samples, bulk.Samples);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ZeroOnlyJournalRemainsV3UntilRecoveryFinalizesItsLegacyFormat(bool withTune)
+ {
+ var run = ContactRun(withTune, zeroFields: true);
+ var store = new RunStore(_directory);
+ await using (var journal = store.CreateJournal(run with { Samples = [] }))
+ {
+ foreach (var sample in run.Samples) await journal.AppendAsync(sample);
+ await journal.FlushAsync();
+ }
+ var path = Path.Combine(_directory, $"{run.Id:N}.partial");
+ using (var reader = File.OpenText(path))
+ using (var header = JsonDocument.Parse((await reader.ReadLineAsync(TestContext.Current.CancellationToken))!))
+ Assert.Equal(RecordedRun.CurrentSchemaVersion, header.RootElement.GetProperty("schemaVersion").GetInt32());
+ var recovered = new RunStore(_directory);
+ Assert.Single(await recovered.ListAsync());
+ var loaded = await recovered.LoadAsync(run.Id);
+ Assert.Equal(WithoutContactFields(run).SchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(WithoutContactFields(run).Samples, loaded.Samples);
+ Assert.True(loaded.IsIncomplete);
+ if (withTune) Assert.True(loaded.TuneAttachment!.DrivingContinuityInterrupted);
+ Assert.False(File.Exists(path));
+ }
+
[Fact]
public async Task NewJournalDeclaresContactCapableFormatBeforeTheFirstSample()
{
@@ -228,11 +252,64 @@ public void ContactTelemetryCannotValidateAsLegacyFormat()
[InlineData(1f, 10_000_001f)]
[InlineData(null, 10f)]
[InlineData(1f, null)]
- public void InvalidOrPartialObjectImpactMetadataCannotEnterTheRunLibrary(float? loss, float? mass)
+ [InlineData(null, 0f)]
+ [InlineData(0f, null)]
+ [InlineData(-1f, 0f)]
+ [InlineData(0f, -1f)]
+ public async Task InvalidOrPartialObjectImpactMetadataCannotEnterTheRunLibrary(float? loss, float? mass)
{
- var sample = RunTestData.CreateRun().Samples[0];
+ var run = ContactRun(withTune: false, zeroFields: true);
+ var sample = run.Samples[0];
sample = sample with { State = sample.State with { SmashableVelocityLossMetersPerSecond = loss, SmashableMassKilograms = mass } };
Assert.Throws(() => RunStore.ValidateSample(sample, null));
+ var store = new RunStore(_directory);
+ await Assert.ThrowsAsync(() => store.SaveAsync(run with { Samples = [sample, run.Samples[1]] }));
+ Assert.False(File.Exists(Path.Combine(_directory, $"{run.Id:N}.wisprun")));
+ }
+
+ private static RecordedRun ContactRun(bool withTune, bool zeroFields)
+ {
+ var run = RunTestData.CreateRun() with
+ {
+ SchemaVersion = RecordedRun.CurrentSchemaVersion,
+ Notes = "Keep recorded metadata",
+ Markers = [new(.05, "Contact")]
+ };
+ var snapshot = withTune ? TuneUiTestData.ValidSnapshot() : null;
+ return run with
+ {
+ TuneAttachment = snapshot is null ? null : new(snapshot, "Saved setup", "Tune snapshot", DateTimeOffset.UtcNow, RunTuneAttachmentKind.CurrentAtStart),
+ Samples = run.Samples.Select(sample => sample with
+ {
+ State = sample.State with
+ {
+ CarOrdinal = snapshot?.Identity.CarOrdinal ?? sample.State.CarOrdinal,
+ Drivetrain = snapshot is null ? sample.State.Drivetrain : (DrivetrainType)snapshot.Identity.Drivetrain,
+ SmashableVelocityLossMetersPerSecond = zeroFields ? 0 : null,
+ SmashableMassKilograms = zeroFields ? 0 : null
+ }
+ }).ToArray()
+ };
+ }
+
+ private static RecordedRun WithoutContactFields(RecordedRun run) => run with
+ {
+ SchemaVersion = run.TuneAttachment is null ? RecordedRun.BaseSchemaVersion : RecordedRun.TuneAttachmentSchemaVersion,
+ Samples = run.Samples.Select(sample => sample with
+ {
+ State = sample.State with { SmashableVelocityLossMetersPerSecond = null, SmashableMassKilograms = null }
+ }).ToArray()
+ };
+
+ private static async Task WriteRawRunAsync(string path, RecordedRun run)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await using var file = File.Create(path);
+ await using var gzip = new GZipStream(file, CompressionLevel.Fastest);
+ await using var writer = new StreamWriter(gzip);
+ await writer.WriteLineAsync(JsonSerializer.Serialize(run with { Samples = [] }, RunStore.JsonOptions));
+ foreach (var sample in run.Samples)
+ await writer.WriteLineAsync(JsonSerializer.Serialize(sample, RunStore.JsonOptions));
}
public void Dispose()
diff --git a/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs b/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs
index f7438f7..2222f52 100644
--- a/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs
+++ b/tests/Wisp.App.Tests/RunTuneAttachmentTests.cs
@@ -141,7 +141,7 @@ public async Task SingleAndBulkArchiveRoundTripsRetainEveryAttachmentField()
[Theory]
[InlineData(false, 2)]
- [InlineData(true, 3)]
+ [InlineData(true, 2)]
public async Task InterruptedJournalRecoversItsSnapshotAndMarksContinuityUnverified(bool newJournal, int expectedVersion)
{
var run = AttachedRun() with { Tune = "Legacy label" };
diff --git a/tests/Wisp.Core.Tests/LapReviewContactsTests.cs b/tests/Wisp.Core.Tests/LapReviewContactsTests.cs
index 1d3a0c3..0abd4a8 100644
--- a/tests/Wisp.Core.Tests/LapReviewContactsTests.cs
+++ b/tests/Wisp.Core.Tests/LapReviewContactsTests.cs
@@ -109,6 +109,72 @@ public void FreshObjectImpulsesAfterAnObservedResetAreSeparateContacts()
Assert.Equal([20, 25], LapReviewContacts.Find(lap, TestContext.Current.CancellationToken).Select(contact => contact.PointIndex));
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ReportedObjectImpulseSurvivesRepeatedLapClockOrGameTick(bool sameTick)
+ {
+ var lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 2 : 0, _ => 150);
+ RepeatClockAtImpact(lap, sameTick);
+ var run = new RecordedRun { Samples = lap.Points.Select(point => point.Sample).ToArray() };
+ var rebuilt = Assert.Single(LapReviewAnalysis.Build(run, LapTimingMode.GameLaps, TestContext.Current.CancellationToken).Laps);
+ var contact = Assert.Single(LapReviewContacts.Find(rebuilt, TestContext.Current.CancellationToken));
+ Assert.Equal(20, contact.PointIndex);
+ Assert.Equal(LapReviewContactKind.SmashableObject, contact.Kind);
+ }
+
+ [Theory]
+ [InlineData("break")]
+ [InlineData("segment")]
+ [InlineData("car")]
+ [InlineData("drivetrain")]
+ [InlineData("notDriving")]
+ [InlineData("menu")]
+ [InlineData("position")]
+ [InlineData("rewind")]
+ [InlineData("clock")]
+ public void SameTickObjectImpulseDoesNotRelaxGapIdentityOrPositionGuards(string change)
+ {
+ var lap = WithImpactFields(Lap(_ => 30, _ => 0), index => index >= 20 ? 2 : 0, _ => 150);
+ RepeatClockAtImpact(lap, sameTick: true);
+ var point = lap.Points[20];
+ lap.Points[20] = change switch
+ {
+ "break" => point with { BreakBefore = true },
+ "segment" => point with { Sample = point.Sample with { Segment = 1 } },
+ "car" => point with { Sample = point.Sample with { State = point.Sample.State with { CarOrdinal = point.Sample.State.CarOrdinal + 1 } } },
+ "drivetrain" => point with { Sample = point.Sample with { State = point.Sample.State with { Drivetrain = point.Sample.State.Drivetrain == DrivetrainType.AllWheelDrive ? DrivetrainType.RearWheelDrive : DrivetrainType.AllWheelDrive } } },
+ "notDriving" => point with { Sample = point.Sample with { IsDriving = false } },
+ "menu" => point with { Sample = point.Sample with { State = point.Sample.State with { IsRaceOn = false } } },
+ "position" => point with { Position = point.Position with { X = point.Position.X + .02f } },
+ "rewind" => point with { LapSeconds = point.LapSeconds - .01 },
+ _ => point with { Sample = point.Sample with { State = point.Sample.State with { GameTimestampMilliseconds = point.Sample.State.GameTimestampMilliseconds - 1 } } }
+ };
+ Assert.Empty(LapReviewContacts.Find(lap, TestContext.Current.CancellationToken));
+ }
+
+ private static void RepeatClockAtImpact(LapReviewLap lap, bool sameTick)
+ {
+ var previous = lap.Points[19];
+ var point = lap.Points[20];
+ var state = point.Sample.State;
+ lap.Points[20] = point with
+ {
+ RunSeconds = sameTick ? previous.RunSeconds : point.RunSeconds,
+ LapSeconds = previous.LapSeconds,
+ Position = sameTick ? previous.Position : point.Position,
+ Sample = point.Sample with
+ {
+ ElapsedSeconds = sameTick ? previous.Sample.ElapsedSeconds : point.Sample.ElapsedSeconds,
+ State = state with
+ {
+ GameTimestampMilliseconds = sameTick ? previous.Sample.State.GameTimestampMilliseconds : state.GameTimestampMilliseconds,
+ Lap = state.Lap! with { CurrentLapSeconds = (float)previous.LapSeconds, Position = sameTick ? previous.Position : point.Position }
+ }
+ }
+ };
+ }
+
[Fact]
public void DirectObjectMarkerCannotCrossATelemetryGapOrStartFromUnavailableData()
{
diff --git a/tests/Wisp.Core.Tests/RunWorkspaceSettingsTests.cs b/tests/Wisp.Core.Tests/RunWorkspaceSettingsTests.cs
index 4cdf36c..5a7de06 100644
--- a/tests/Wisp.Core.Tests/RunWorkspaceSettingsTests.cs
+++ b/tests/Wisp.Core.Tests/RunWorkspaceSettingsTests.cs
@@ -8,6 +8,23 @@ namespace Wisp.Core.Tests;
public sealed class RunWorkspaceSettingsTests
{
+ [Theory]
+ [InlineData(0, 0)]
+ [InlineData(60, 70)]
+ [InlineData(920, 920)]
+ [InlineData(1800, 1400)]
+ [InlineData(double.NaN, 0)]
+ [InlineData(double.PositiveInfinity, 0)]
+ public void LapMapHeightIsBoundedAndSurvivesWorkspaceCloning(double input, double expected)
+ {
+ var settings = new RunWorkspaceSettings { LapMapHeight = input };
+ settings.Normalize();
+ Assert.Equal(expected, settings.LapMapHeight);
+ var clone = settings.Clone();
+ Assert.Equal(expected, clone.LapMapHeight);
+ Assert.Equal(expected, JsonSerializer.Deserialize(JsonSerializer.Serialize(clone))!.LapMapHeight);
+ }
+
[Fact]
public void CatalogHasElevenDistinctModulesAndEveryPresetUsesKnownModules()
{
diff --git a/tools/Wisp.UiReview/LapRecordedComparisonReview.cs b/tools/Wisp.UiReview/LapRecordedComparisonReview.cs
index 58669ae..2184c20 100644
--- a/tools/Wisp.UiReview/LapRecordedComparisonReview.cs
+++ b/tools/Wisp.UiReview/LapRecordedComparisonReview.cs
@@ -132,9 +132,14 @@ internal static int Run(IReadOnlyList sourceFiles, string output, Func= 0 && track.ComparisonData?.Cursor == chosenReference && track.FocusedLap == 2, name + "/B-selection-independent");
+ review.ScrubBoth = true; Pump();
+ review.PickReferencePoint(review.Reference.Points.Length - 1); Pump();
+ Check(review.Cursor == review.MaximumCursor && track.ComparisonData?.Cursor == review.Reference.Points.Length - 1,
+ name + "/Both-B-selection-moves-both-recorded-laps");
track.ShowAll(); track.CompleteCameraMotionForTest(); Pump();
Check(track.FocusedLap == 0 && track.HasBothPreparedModels, name + "/overview-retains-both");
stage = name + "/resize";
diff --git a/tools/Wisp.UiReview/LapReviewUiChecks.cs b/tools/Wisp.UiReview/LapReviewUiChecks.cs
index 57f2f45..559d690 100644
--- a/tools/Wisp.UiReview/LapReviewUiChecks.cs
+++ b/tools/Wisp.UiReview/LapReviewUiChecks.cs
@@ -37,6 +37,15 @@ internal static void Run(string unusedStoreDirectory, Action check
};
try
{
+ var tooltip = new ToolTip { Content = "Drag to rotate · Shift/right-drag to pan · Ctrl-drag to roll · Right-click or click empty space to enable scroll zoom · Double-click to reset" };
+ tooltip.ApplyTemplate();
+ tooltip.Measure(new Size(400, 200)); tooltip.Arrange(new Rect(tooltip.DesiredSize)); tooltip.UpdateLayout();
+ var tipText = Descendants(tooltip).OfType().First();
+ var tipBackground = ((SolidColorBrush)tooltip.Background).Color;
+ var tipForeground = ((SolidColorBrush)tipText.Foreground).Color;
+ check(Math.Abs(tipBackground.R - tipForeground.R) + Math.Abs(tipBackground.G - tipForeground.G) +
+ Math.Abs(tipBackground.B - tipForeground.B) > 240 && tooltip.ActualWidth <= 400 && tipText.TextWrapping == TextWrapping.Wrap,
+ "map-help-tooltip-is-themed-readable-and-bounded");
VisualTreeHelper.SetRootDpi(surface, new DpiScale(1, 1));
Await(model.ShowReviewAsync(LapReviewFixtures.Create(), LapReviewFixtures.Create(reference: true)));
Ready(model);
@@ -152,6 +161,8 @@ internal static void Run(string unusedStoreDirectory, Action check
check(review.Is3D && view3D.IsChecked == true && track.Visibility == Visibility.Collapsed &&
track3D.Visibility == Visibility.Visible, name + "/3d-mode-binding");
check(track3D.IsReady && track3D.PreparedSegmentCount > 0, name + "/3d-scene-ready");
+ check(track3D.ToolTip is null && ((TextBlock)view.FindName("MapInteractionHint")).Text.Contains("Right-click"),
+ name + "/camera-help-does-not-cover-the-track-on-hover");
check(!review.SharedSpace && !review.IsMapComparison && !track3D.IsComparison && track3D.ComparisonData is null &&
track3D.Data is { ShowReferencePath: true, HasDistinctReference: true } originalMap &&
ReferenceEquals(originalMap.Reference, selectedReference) && showBoth.Visibility == Visibility.Collapsed,
@@ -181,8 +192,8 @@ internal static void Run(string unusedStoreDirectory, Action check
check(view.FindName("CameraAxes") is null && !Descendants(view).OfType().Any(item =>
AutomationProperties.GetName(item).StartsWith("3D camera", StringComparison.Ordinal)) &&
!Descendants(view).OfType().Any(item => AutomationProperties.GetName(item) == "Lap contact markers") &&
- !Descendants(view).OfType().Any(item => ReferenceEquals(item.Command, review.MarkContactCommand) ||
- ReferenceEquals(item.Command, review.RemoveContactCommand)), name + "/no-manual-angle-or-contact-editors");
+ !Descendants(view).OfType().Any(item => Equals(item.Content, "Mark contact here") ||
+ Equals(item.Content, "Remove marked contact")), name + "/no-manual-angle-or-contact-editors");
ScrollTo(elevation); Capture("legend-graph-settings");
CheckMapAndScrubber(); Capture("map-3d-normal");
VerifyMapImage("3d-normal", track3D);
@@ -276,11 +287,11 @@ internal static void Run(string unusedStoreDirectory, Action check
check(track3D.FocusedLap == 2 && track3D.RenderSize == overviewSize &&
ProjectedTrackBounds(reference: true).Width > overviewB.Width * 1.15, name + "/focus-B-moves-shared-camera");
CheckBothGeometriesRetained();
- var referenceCursor = track3D.ComparisonData!.Cursor;
- var nextReference = Math.Clamp(referenceCursor + 1, 0, review.Reference!.Points.Length - 1);
- var expectedCursor = track3D.ComparisonData.Comparison?.Points.ElementAtOrDefault(nextReference)?.ReferencePointIndex;
+ var primaryCursor = review.Cursor;
+ var referenceSeconds = review.Plot.Comparison!.Points[primaryCursor].ReferenceLapSeconds;
SendKey(track3D, Key.Right, surface); Pump();
- check(expectedCursor is { } matched && review.Cursor == matched && trace.Data?.Cursor == matched,
+ check(review.Cursor > primaryCursor && trace.Data?.Cursor == review.Cursor &&
+ review.Plot.Comparison.Points[review.Cursor].ReferenceLapSeconds > referenceSeconds,
name + "/focused-reference-arrow-selects-corresponding-A-position");
ScrollTo(scrubBar); Capture("map-3d-focus-B");
VerifyMapImage("3d-focus-B", track3D);
@@ -311,15 +322,27 @@ internal static void Run(string unusedStoreDirectory, Action check
var automaticHeight = mapViewport.ActualHeight;
var geometryBeforeResize = track3D.SceneBuildCount;
var grip = (Thumb)view.FindName("MapResizeGrip");
+ grip.RaiseEvent(new DragDeltaEventArgs(0, -1) { RoutedEvent = Thumb.DragDeltaEvent });
+ Arrange(surface, size);
+ check(mapViewport.ActualHeight <= automaticHeight + .1, name + "/first-upward-resize-never-enlarges-map");
+ SendKey(grip, Key.Home, surface); Arrange(surface, size);
grip.RaiseEvent(new DragDeltaEventArgs(0, 160) { RoutedEvent = Thumb.DragDeltaEvent });
grip.RaiseEvent(new DragDeltaEventArgs(0, 40) { RoutedEvent = Thumb.DragDeltaEvent });
+ check(settings.RunWorkspace.LapMapHeight == 0, name + "/resize-does-not-save-every-drag-event");
+ grip.RaiseEvent(new DragCompletedEventArgs(0, 200, false) { RoutedEvent = Thumb.DragCompletedEvent });
Arrange(surface, size);
- check(Math.Abs(mapViewport.ActualHeight - Math.Clamp(automaticHeight + 200, 180, 1400)) < .1 &&
+ check(Math.Abs(mapViewport.ActualHeight - Math.Clamp(automaticHeight + 200, 70, 1400)) < .1 &&
track3D.SceneBuildCount == geometryBeforeResize, name + "/track-resize-accumulates-without-rebuilding-geometry");
+ check(Math.Abs(settings.RunWorkspace.LapMapHeight - mapViewport.ActualHeight) < .1,
+ name + "/resize-height-saved-on-release");
+ model.SaveWorkspaceCommand.Execute(null); Pump();
+ check(Math.Abs(settings.RunWorkspace.LapMapHeight - mapViewport.ActualHeight) < .1,
+ name + "/saving-graph-layout-preserves-map-height");
VerifyMapImage("3d-taller", track3D);
SendKey(grip, Key.Home, surface); Arrange(surface, size);
check(double.IsNaN(view.RequestedMapHeight) && Math.Abs(mapViewport.ActualHeight - automaticHeight) < .1,
name + "/track-size-keyboard-reset");
+ check(settings.RunWorkspace.LapMapHeight == 0, name + "/automatic-height-reset-is-saved");
view2D.SetCurrentValue(ToggleButton.IsCheckedProperty, true); Pump(); Arrange(surface, size);
check(!review.Is3D && track.Visibility == Visibility.Visible && track3D.Visibility == Visibility.Collapsed &&
track.Data?.Cursor == review.Cursor &&
From ee46350ced2ec6597f6ea114ac9331abca725d4b Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Wed, 7 Oct 2026 00:58:59 -0500
Subject: [PATCH 14/15] Verify zero-impact recording compatibility through the
Runs workflow
---
tests/Wisp.App.Tests/RunsViewModelTests.cs | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/tests/Wisp.App.Tests/RunsViewModelTests.cs b/tests/Wisp.App.Tests/RunsViewModelTests.cs
index 8a98d55..33178d4 100644
--- a/tests/Wisp.App.Tests/RunsViewModelTests.cs
+++ b/tests/Wisp.App.Tests/RunsViewModelTests.cs
@@ -471,7 +471,17 @@ await WithLiveReceiver(async (receiver, refreshTelemetry) =>
var recorded = await service.StopAsync();
Assert.NotNull(recorded); Assert.NotEmpty(recorded.Samples); Assert.Null(recorded.TuneAttachment);
var loaded = await service.Store.LoadAsync(recorded.Id);
- Assert.Equal(recorded.Samples, loaded.Samples); Assert.Null(loaded.TuneAttachment);
+ Assert.All(recorded.Samples, sample =>
+ {
+ Assert.Equal(0f, sample.State.SmashableVelocityLossMetersPerSecond);
+ Assert.Equal(0f, sample.State.SmashableMassKilograms);
+ });
+ Assert.Equal(RecordedRun.BaseSchemaVersion, loaded.SchemaVersion);
+ Assert.Equal(recorded.Samples.Select(sample => sample with
+ {
+ State = sample.State with { SmashableVelocityLossMetersPerSecond = null, SmashableMassKilograms = null }
+ }), loaded.Samples);
+ Assert.Null(loaded.TuneAttachment);
await refreshTelemetry(); model.RefreshStatus();
await model.ToggleRecordingAsync(); clock.Advance(TimeSpan.FromSeconds(3)); await refreshTelemetry(); model.RefreshStatus();
Assert.Equal(2, reads); Assert.True(model.IsRecording, model.Error);
From 89325bd5b92daaf3d841db5e94ade3b8abcff9f3 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Wed, 7 Oct 2026 10:59:02 -0500
Subject: [PATCH 15/15] Prepare Wisp 2.6.5 lap review release and public guide
---
CHANGELOG.md | 7 +++
LICENSES/library-sources-distribution.json | 6 +--
README.md | 21 ++++----
docs/LAP-REVIEW-3D-PRIVATE.md | 3 ++
docs/LAP-REVIEW.md | 49 +++++++++++++++++--
docs/releases/Wisp-2.6.5-release-notes.md | 12 +++++
docs/run-library-format.md | 2 +-
installer/Wisp.iss | 2 +-
src/Wisp.App/ReleaseNotesCatalog.cs | 15 +++++-
src/Wisp.App/Wisp.App.csproj | 6 +--
src/Wisp.App/app.manifest | 2 +-
src/Wisp.Updater/Wisp.Updater.csproj | 6 +--
.../ApplicationVersionInfoTests.cs | 14 +++---
.../ReleaseNotesCatalogTests.cs | 4 +-
14 files changed, 115 insertions(+), 34 deletions(-)
create mode 100644 docs/releases/Wisp-2.6.5-release-notes.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52d6896..96cca1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
Notable changes to Wisp are recorded here.
+## 2.6.5 - 2026-10-07
+
+- Add an optional 3D lap-review map with recorded elevation and mouse controls for rotation, pan and zoom. Keep the existing 2D view.
+- Add Shared space to compare two lap maps with the same color scale, focus either lap and move their cursors together or independently.
+- Add power and torque channels, a color legend, contact markers and PNG export. Distinguish recorded object contacts from estimated possible contacts.
+- Move lap-position controls above the map and add a resizable map area with a camera reset button.
+
## 2.6.4 - 2026-10-06
- Improve HUD and Tune compatibility with Forza updates on Steam and Xbox PC.
diff --git a/LICENSES/library-sources-distribution.json b/LICENSES/library-sources-distribution.json
index 500f072..6b8823f 100644
--- a/LICENSES/library-sources-distribution.json
+++ b/LICENSES/library-sources-distribution.json
@@ -1,9 +1,9 @@
{
"schemaVersion": 1,
- "version": "2.6.4",
+ "version": "2.6.5",
"archive": {
- "filename": "Wisp-2.6.4-library-sources.zip",
- "url": "https://github.com/Views2k/Wisp/releases/download/library-sources-2.6.4/Wisp-2.6.4-library-sources.zip",
+ "filename": "Wisp-2.6.5-library-sources.zip",
+ "url": "https://github.com/Views2k/Wisp/releases/download/library-sources-2.6.5/Wisp-2.6.5-library-sources.zip",
"bytes": 879043478,
"sha256": "6ee69a19f4b28c198c79f5f48b11a639bd7d6372b36f333c3ee4873aaf6d5ccc"
}
diff --git a/README.md b/README.md
index 062d371..8241960 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Supports Steam and Xbox app / Microsoft Store editions on Windows PC.
Versions before 2.4 can show delayed or choppy tachometer motion on NVIDIA systems when G-SYNC/VRR is enabled. I recommend updating to the latest release.
- Download Wisp 2.6.4 ·
+ Download Wisp 2.6.5 ·
Website ·
Changelog ·
Architecture
@@ -13,11 +13,11 @@
Buy Me a Coffee
-## Wisp 2.6.4
+## Wisp 2.6.5
-2.6.4 improves HUD and Tune compatibility with Forza updates on Steam and Xbox PC, restores Drift Zone angle-bonus guidance on the latest builds, and adds a dismissible note from Views.
+2.6.5 adds 3D lap review with recorded elevation, shared-space comparison, channel legends, contact markers and PNG export.
-[Clips](#clips) · [Tune](#tune) · [Lap review](#lap-review) · [Live map and lap delta](#live-map-and-lap-delta) · [Install](#install) · [2.6.4 release notes](docs/releases/Wisp-2.6.4-release-notes.md)
+[Clips](#clips) · [Tune](#tune) · [Lap review](#lap-review) · [Live map and lap delta](#live-map-and-lap-delta) · [Install](#install) · [2.6.5 release notes](docs/releases/Wisp-2.6.5-release-notes.md)
### Clips
@@ -59,7 +59,9 @@ Current-car reading supports **Steam and Xbox app / Microsoft Store FH6 with imp
### Lap review
-Open a saved run in **Runs → Lap review** to inspect the line you drove. Move one cursor across the map and graphs to review speed, inputs, G-force, RPM, gear and wheel readings. Choose a section and compare its time, distance and inputs with another lap or a pinned benchmark. Events mark braking, throttle pickup and shifts.
+Open a saved run in **Runs → Lap review** to inspect the line you drove in **2D or 3D**. The 3D view preserves recorded elevation at the same scale as horizontal distance. Rotate, pan and zoom the map, color it by speed, inputs, G-force, RPM, gear, wheel readings, horsepower, torque or elevation, and follow the selected point in the graph. Choose a section and compare its time, distance and inputs with another lap or a pinned benchmark. Events mark braking, throttle pickup and shifts.
+
+Optional **Shared space** places two laps beside each other with one camera and color scale. Focus either track, move both cursors together or inspect A and B independently, then return to the overview. Resize the map and use **Save PNG** to share the current view and legend. Contact markers distinguish recorded object evidence and saved annotations from possible-contact estimates; they do not identify every collision. [Read the lap-review guide](docs/LAP-REVIEW.md).
Turn on **Save completed laps automatically** to keep laps for later review, grouped by day and car. It uses the timing mode selected in HUD settings while Wisp is running. Choosing Run B takes priority over a compatible pinned benchmark.
@@ -244,8 +246,8 @@ An optional quick tour introduces Clips, Tune, live map, lap delta and lap
review. Start from the welcome banner or choose **Replay the quick tour**
in Release Notes.
-[Download Wisp 2.6.4](https://github.com/Views2k/Wisp/releases/download/v2.6.4/Wisp-Setup-2.6.4.exe) ·
-[2.6.4 release notes](docs/releases/Wisp-2.6.4-release-notes.md) ·
+[Download Wisp 2.6.5](https://github.com/Views2k/Wisp/releases/download/v2.6.5/Wisp-Setup-2.6.5.exe) ·
+[2.6.5 release notes](docs/releases/Wisp-2.6.5-release-notes.md) ·
[Changelog](CHANGELOG.md)
Older interface screenshots retain their original version labels. The gameplay
@@ -372,11 +374,11 @@ require administrator access or a separate .NET runtime.
## Install
-1. Download [Wisp-Setup-2.6.4.exe](https://github.com/Views2k/Wisp/releases/download/v2.6.4/Wisp-Setup-2.6.4.exe).
+1. Download [Wisp-Setup-2.6.5.exe](https://github.com/Views2k/Wisp/releases/download/v2.6.5/Wisp-Setup-2.6.5.exe).
2. Run the installer.
3. Complete the required setup wizard on first launch.
-The [release page](https://github.com/Views2k/Wisp/releases/tag/v2.6.4) also provides the installer checksum and a ZIP containing the same installer. To verify the download, compare its SHA-256 with the `.sha256` file:
+The [release page](https://github.com/Views2k/Wisp/releases/tag/v2.6.5) also provides the installer checksum and a ZIP containing the same installer. To verify the download, compare its SHA-256 with the `.sha256` file:
**[WINDOWS POWERSHELL]**
@@ -496,6 +498,7 @@ To build the self-contained installer:
## Documentation
+- [Wisp 2.6.5 release notes](docs/releases/Wisp-2.6.5-release-notes.md)
- [Wisp 2.6.4 release notes](docs/releases/Wisp-2.6.4-release-notes.md)
- [Wisp 2.6.3 release notes](docs/releases/Wisp-2.6.3-release-notes.md)
- [Wisp 2.6.2 release notes](docs/releases/Wisp-2.6.2-release-notes.md)
diff --git a/docs/LAP-REVIEW-3D-PRIVATE.md b/docs/LAP-REVIEW-3D-PRIVATE.md
index 1f7236d..e70ea08 100644
--- a/docs/LAP-REVIEW-3D-PRIVATE.md
+++ b/docs/LAP-REVIEW-3D-PRIVATE.md
@@ -1,5 +1,8 @@
# Lap review 3D private test
+Historical engineering notes for the private test builds that became Wisp 2.6.5.
+For current controls and usage, see the [lap-review guide](LAP-REVIEW.md).
+
The 2D review stays the default. 3D uses saved XYZ positions with the same scale
on every axis; the ground projection is a depth cue, not a terrain reconstruction.
Camera and cursor changes reuse frozen geometry. Scene preparation is cancellable
diff --git a/docs/LAP-REVIEW.md b/docs/LAP-REVIEW.md
index 3746a8e..052d213 100644
--- a/docs/LAP-REVIEW.md
+++ b/docs/LAP-REVIEW.md
@@ -1,19 +1,62 @@
# Lap review
-This describes the unreleased lap-review candidate. The public 2.5.2 installer does not include it.
+Review the line you drove, inspect its telemetry and compare selected sections with another lap. The map uses saved positions from your recording.
+
+## Record and open laps
Open **Runs → Lap review**. Enable **Save completed laps automatically** to retain detailed laps while Wisp is running. Capture uses the timing mode in the HUD's lap settings: Race / Rivals laps or Time Attack. It works with the lap overlays disabled. Manual run recording remains available; older recordings need position and lap telemetry to show a driven map.
Automatic capture uses the existing lap tracker's accepted completion boundary. Joining halfway through a lap, missing telemetry, rewinding, changing car or exceeding the recording limit prevents that interrupted lap from being saved as a complete capture. Completed saves are written to the existing local run library, with its storage limits and export/removal tools. Turn the option off to stop future captures. Pending completed saves finish when Wisp closes.
-Choose a saved run and a lap. The map is the car's recorded path, not a supplied circuit outline or a claim about track limits. The start ring and shared cursor identify locations. Click the map or graph, use arrow keys, or move the sample slider. Choose speed, time delta, throttle, brake, steering input, G-force, RPM or gear as the channel. Tire temperature, slip ratio, raw slip angle and normalized suspension travel also have a wheel selector for the map and graph. The sample readout shows these values for all four wheels together.
+## Map, channels and cursor
+
+Choose a saved run and a lap. The map is the car's recorded path, not a supplied circuit outline or a claim about track limits. The start ring and shared cursor identify locations. Click the map or graph, use arrow keys, or move the **Lap position** slider above the map.
+
+**2D** is the default. Switch to **3D** to view the saved XYZ positions with the same physical scale on every axis, preserving the recorded elevation changes. The ground projection helps show depth; it does not reconstruct terrain or track boundaries. Elevation values use feet when speed is set to mph and meters otherwise.
+
+Use **Color** to choose speed, time delta, throttle, brake, steering input, lateral/longitudinal/combined G-force, RPM, gear, horsepower, torque or elevation. Tire temperature, slip ratio, raw slip angle and normalized suspension travel also have a wheel selector for the map and graph. The legend shows the channel, units and numeric range; the sample readout shows wheel values for all four wheels together. Unavailable data stays unavailable.
+
+### 3D controls
+
+| Input | Action |
+| --- | --- |
+| Click a track point | Select that location; in Shared space, move closer to that lap |
+| Left-drag | Rotate |
+| Shift-drag, right-drag or middle-drag | Pan |
+| Ctrl-drag | Roll |
+| Mouse wheel while the map has focus | Zoom |
+| Arrow keys while the map has focus | Step through recorded points |
+| Home / End while the map has focus | Select the first / last point |
+| Double-click, R, or **Reset camera** | Reset the camera |
+
+Right-click or click empty map space to focus it without changing the selected point. Before the map has focus, the wheel scrolls the page. **Zoom in** and **Zoom out** are also available as buttons. Picking another point on an already focused lap keeps your adjusted camera view.
+
+Drag **Resize map** to change the map height; Wisp remembers the height when resizing ends. With the resize handle focused, Up/Down adjusts the height and Home restores automatic sizing.
+
+**Save PNG** saves the current 2D or 3D map with its channel legend and selected-point values. It includes the current camera and Shared space view when enabled. Resize and frame the map before saving, and choose an unused filename to keep existing images.
+
+## Sections and reference laps
Use **Start section here** and **End section here** around a corner or straight. Compare entry, minimum and exit speed, time, distance, throttle/brake/coasting fractions, G-force, RPM, gear changes and tire values. **Open section in graphs** connects the same interval to the existing Runs graph workspace. Whole-lap timing uses the recorded timing boundary; section statistics describe the actual recorded samples and their coverage.
-Select Run B using the existing comparison controls, then choose its reference lap. Without Run B, the reference picker uses laps from the current run. **Pin this lap as benchmark** keeps a selected run/lap reference between Wisp sessions; rename the run and add its tune label in the normal run details. Pinning does not copy the recording, prevent its removal, or change the live HUD's session-best/previous-lap reference. If the pinned run is unavailable, choose another or unpin it.
+Select Run B using the existing comparison controls, then choose its reference lap. Run B takes priority over a compatible pinned benchmark. Without Run B or a compatible pinned benchmark, the reference picker uses laps from the current run. **Pin this lap as benchmark** keeps a selected run/lap reference between Wisp sessions; rename the run and add its tune label in the normal run details. Pinning does not copy the recording, prevent its removal, or change the live HUD's session-best/previous-lap reference. If the pinned run is unavailable, choose another or unpin it.
Time delta is this lap's time minus the reference's time at matching world positions. A negative value is ahead. Route direction, continuity, start location, car and timing mode must agree, and most of both routes must match. Ambiguous or missing positions get no delta. Comparison does not establish identical tune, conditions or an officially clean lap. Existing telemetry does not provide a reliable official lap-validity flag or full track boundary.
+### Shared space and independent cursors
+
+In 3D, enable **Shared space** when two distinct laps with recorded positions are selected. It is optional and starts off. The laps are arranged beside each other in one scene, using a common physical scale and channel color range while retaining their recorded elevation. Click either track to focus it. Zoom out, choose **Show both laps**, or press Escape while the map has focus to return to the overview.
+
+Choose **Both** to move the cursors together. Compatible comparisons follow matching track positions. When the laps cannot be matched, Both follows relative progress by recorded distance; this does not enable time delta or matched section statistics. **A** and **B** let you inspect the laps independently while keeping the other cursor fixed. Moving B independently leaves A and its graph at their selected point.
+
+Shared space can display different cars or routes when both laps have usable positions. That visual comparison does not relax the timing and benchmark requirements above.
+
+## Contact markers
+
+**Show contacts** displays filled stars for recorded breakable-object evidence or existing saved Contact annotations, and hollow stars for **Possible contact (estimate)**. Object evidence comes from the game's reported breakable-object velocity loss and mass. Possible contacts are inferred from movement; they can be wrong. These markers do not establish every wall/car collision, an officially clean lap, or the absence of contact when no marker appears.
+
+Saved or imported Contact annotations remain visible. There is no manual contact editor. Older recordings that did not retain object telemetry cannot recover that evidence retrospectively. See the [run-file format guide](run-library-format.md) for sharing and older-version compatibility.
+
## Why these measurements
RACELOGIC recommends locating time losses in the delta trace, then examining braking location, apex speed and the line through corner entry and exit. Wisp uses the corresponding telemetry and selected sections; it does not infer where a real apex or legal kerb lies from a bare path. [Circuit Tools guidance](https://en.racelogic.support/motorsport/discontinued-legacy/software/circuit-tools-2/kb/how-to-use-circuit-tools-to-become-faster/).
diff --git a/docs/releases/Wisp-2.6.5-release-notes.md b/docs/releases/Wisp-2.6.5-release-notes.md
new file mode 100644
index 0000000..966b786
--- /dev/null
+++ b/docs/releases/Wisp-2.6.5-release-notes.md
@@ -0,0 +1,12 @@
+# Wisp 2.6.5
+
+[Download Wisp 2.6.5 for Windows](https://github.com/Views2k/Wisp/releases/download/v2.6.5/Wisp-Setup-2.6.5.exe)
+
+## Lap review
+
+- Added an optional **3D track map** with recorded elevation and mouse controls for rotation, pan and zoom. The 2D view remains available.
+- Added **Shared space** to compare two lap maps with the same color scale. Focus either lap or view both, and move their cursors together or independently.
+- Added power and torque channels, a color legend, contact markers and **Save PNG**. Recorded object contacts and estimated possible contacts use distinct markers.
+- Moved lap-position controls above the map and added a resizable map area with a camera reset button.
+
+[Lap-review guide](https://github.com/Views2k/Wisp/blob/v2.6.5/docs/LAP-REVIEW.md)
diff --git a/docs/run-library-format.md b/docs/run-library-format.md
index 3fb315e..f775092 100644
--- a/docs/run-library-format.md
+++ b/docs/run-library-format.md
@@ -13,7 +13,7 @@ JSON uses camel-case property names and string enum names. Unknown properties an
Schema 1 stores telemetry without a structured tune attachment. Schema 2 adds that attachment. Schema 3 permits optional breakable-object velocity-loss and mass fields, with or without a tune attachment. Missing object fields mean unavailable evidence, not a measured zero. These fields are validated as a pair; invalid or partial values are rejected.
-When finalizing a new recording or automatic lap, Wisp retains schema 3 if either object field is positive in any sample. All surrounding zero baselines remain intact, so an observed zero-to-positive contact can still be identified. If neither field is ever positive, Wisp omits these optional fields and saves schema 1 or 2 as appropriate. Those completed files remain readable in Wisp 2.6.4; files retaining schema 3 require the contact-capable build. Existing finalized files and imports are not downgraded, and metadata edits preserve their schema and telemetry.
+When finalizing a new recording or automatic lap, Wisp retains schema 3 if either object field is positive in any sample. All surrounding zero baselines remain intact, so an observed zero-to-positive contact can still be identified. If neither field is ever positive, Wisp omits these optional fields and saves schema 1 or 2 as appropriate. Those completed files remain readable in Wisp 2.6.4; files retaining schema 3 require Wisp 2.6.5 or newer. Existing finalized files and imports are not downgraded, and metadata edits preserve their schema and telemetry.
New recording journals declare schema 3 from the start, so older readers cannot recover a prefix and silently discard later object evidence. Recovery validates its retained samples before choosing the finalized format by the same rule and marks the run incomplete. Unsupported or unrecoverable journals remain on disk.
diff --git a/installer/Wisp.iss b/installer/Wisp.iss
index c97c2dd..8dd7463 100644
--- a/installer/Wisp.iss
+++ b/installer/Wisp.iss
@@ -1,5 +1,5 @@
#define MyAppName "Wisp"
-#define MyAppVersion "2.6.4"
+#define MyAppVersion "2.6.5"
#define MyAppDisplayVersion MyAppVersion
#define MyAppOutputVersion MyAppVersion
#define MyAppPublisher "Wisp"
diff --git a/src/Wisp.App/ReleaseNotesCatalog.cs b/src/Wisp.App/ReleaseNotesCatalog.cs
index 3bfd668..bc49142 100644
--- a/src/Wisp.App/ReleaseNotesCatalog.cs
+++ b/src/Wisp.App/ReleaseNotesCatalog.cs
@@ -14,12 +14,25 @@ public static class ReleaseNotesCatalog
{
public static IReadOnlyList Entries { get; } =
[
+ new(
+ "2.6.5",
+ "October 7, 2026",
+ "3D LAP REVIEW",
+ "Review recorded elevation in 3D, compare two laps in a shared space and save the map as a PNG.",
+ true,
+ [
+ Group("Lap review",
+ "Added an optional 3D track map with recorded elevation and mouse controls for rotation, pan and zoom. The 2D view remains available.",
+ "Added Shared space to compare two lap maps with the same color scale. Focus either lap or view both, and move their cursors together or independently.",
+ "Added power and torque channels, a color legend, contact markers and Save PNG. Recorded object contacts and estimated possible contacts use distinct markers.",
+ "Moved lap-position controls above the map and added a resizable map area with a camera reset button.")
+ ]),
new(
"2.6.4",
"October 6, 2026",
"FORZA COMPATIBILITY",
"Improved HUD and Tune compatibility with Forza updates, restored Drift Zone guidance and a note from Views.",
- true,
+ false,
[
Group("Fixes and improvements",
"Improved HUD and Tune compatibility with Forza updates on Steam and Xbox PC.",
diff --git a/src/Wisp.App/Wisp.App.csproj b/src/Wisp.App/Wisp.App.csproj
index 2acee35..ca39899 100644
--- a/src/Wisp.App/Wisp.App.csproj
+++ b/src/Wisp.App/Wisp.App.csproj
@@ -11,9 +11,9 @@
Wisp
Wisp
Wheel-indicated speed and G-force display for Forza Horizon 6.
- 2.6.4
- 2.6.4.0
- 2.6.4.0
+ 2.6.5
+ 2.6.5.0
+ 2.6.5.0
true
false
false
diff --git a/src/Wisp.App/app.manifest b/src/Wisp.App/app.manifest
index 780d7dc..5375b1b 100644
--- a/src/Wisp.App/app.manifest
+++ b/src/Wisp.App/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/src/Wisp.Updater/Wisp.Updater.csproj b/src/Wisp.Updater/Wisp.Updater.csproj
index 12047b8..6ec02a2 100644
--- a/src/Wisp.Updater/Wisp.Updater.csproj
+++ b/src/Wisp.Updater/Wisp.Updater.csproj
@@ -12,9 +12,9 @@
Wisp
Wisp Update Helper
Applies verified Wisp updates after the app exits.
- 2.6.4
- 2.6.4.0
- 2.6.4.0
+ 2.6.5
+ 2.6.5.0
+ 2.6.5.0
diff --git a/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs b/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
index c4c52d1..ad6be10 100644
--- a/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
+++ b/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
@@ -36,8 +36,8 @@ public void DisplayFormattingDoesNotChangeMachineIdentity(string machine, string
[Fact]
public void CurrentVersionLabelsShareTheAssemblyVersion()
{
- Assert.Equal("2.6.4", ApplicationVersionInfo.MachineVersion);
- Assert.Equal("2.6.4", ApplicationVersionInfo.DisplayVersion);
+ Assert.Equal("2.6.5", ApplicationVersionInfo.MachineVersion);
+ Assert.Equal("2.6.5", ApplicationVersionInfo.DisplayVersion);
var project = ProjectMetadata();
Assert.Equal(project.GetValueOrDefault("WispDiagnosticBuildId"), ApplicationVersionInfo.DiagnosticBuildId);
Assert.Equal(project.GetValueOrDefault("WispDiagnosticBuildLabel"), ApplicationVersionInfo.DiagnosticBuildLabel);
@@ -49,13 +49,13 @@ public void CurrentVersionLabelsShareTheAssemblyVersion()
Assert.Null(ApplicationVersionInfo.DiagnosticBuildLabel);
if (ApplicationVersionInfo.IsPrivateCandidate)
{
- Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.6.4 (private test)", ApplicationVersionInfo.FooterText);
- Assert.Contains("private 2.6.4 candidate. It has not been published.", ApplicationVersionInfo.ReleaseHistoryIntroduction);
+ Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.6.5 (private test)", ApplicationVersionInfo.FooterText);
+ Assert.Contains("private 2.6.5 candidate. It has not been published.", ApplicationVersionInfo.ReleaseHistoryIntroduction);
}
else
{
- Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.6.4", ApplicationVersionInfo.FooterText);
- Assert.Contains("current 2.6.4 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction);
+ Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.6.5", ApplicationVersionInfo.FooterText);
+ Assert.Contains("current 2.6.5 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction);
}
}
else
@@ -90,7 +90,7 @@ public void TuneAndClipsReleaseMatchesExpectedBuildIdentity()
Assert.False(ApplicationVersionInfo.IsPrivateCandidate);
Assert.False(ApplicationVersionInfo.LapDiagnosticsEnabled);
Assert.DoesNotContain("private", ApplicationVersionInfo.FooterText, StringComparison.Ordinal);
- Assert.Contains("current 2.6.4 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction,
+ Assert.Contains("current 2.6.5 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction,
StringComparison.Ordinal);
}
diff --git a/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs b/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs
index ebc8549..4aea486 100644
--- a/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs
+++ b/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs
@@ -8,10 +8,10 @@ public sealed class ReleaseNotesCatalogTests
public void CurrentReleaseAndEarlierVersionsAppearInDescendingOrder()
{
Assert.Equal(
- ["2.6.4", "2.6.3", "2.6.2", "2.6.1", "2.6", "2.5.3", "2.5.2", "2.5.1", "2.5", "2.4.1", "2.4", "2.3.4", "2.3.3", "2.3.2", "2.3.1", "2.3", "2.2", "2.1.2", "2.1.1", "2.1", "2.0.1", "2.0", "1.2.2", "1.2.1", "1.2", "1.1.4", "1.1.3", "1.1.2", "1.1.1", "1.1", "1.0.12", "1.0.11", "1.0.10", "1.0.8", "1.0.7", "1.0.6", "1.0.5", "1.0.4", "1.0.3", "1.0.2", "1.0.1"],
+ ["2.6.5", "2.6.4", "2.6.3", "2.6.2", "2.6.1", "2.6", "2.5.3", "2.5.2", "2.5.1", "2.5", "2.4.1", "2.4", "2.3.4", "2.3.3", "2.3.2", "2.3.1", "2.3", "2.2", "2.1.2", "2.1.1", "2.1", "2.0.1", "2.0", "1.2.2", "1.2.1", "1.2", "1.1.4", "1.1.3", "1.1.2", "1.1.1", "1.1", "1.0.12", "1.0.11", "1.0.10", "1.0.8", "1.0.7", "1.0.6", "1.0.5", "1.0.4", "1.0.3", "1.0.2", "1.0.1"],
ReleaseNotesCatalog.Entries.Select(entry => entry.Version));
Assert.True(ReleaseNotesCatalog.Entries[0].IsCurrent);
- Assert.Equal("FORZA COMPATIBILITY", ReleaseNotesCatalog.Entries[0].Label);
+ Assert.Equal("3D LAP REVIEW", ReleaseNotesCatalog.Entries[0].Label);
Assert.Equal("HUD PLACEMENT", ReleaseNotesCatalog.Entries.Single(entry => entry.Version == "2.5.3").Label);
var publicChanges = Assert.Single(ReleaseNotesCatalog.Entries.Single(entry => entry.Version == "2.3.2").Groups,
group => group.Heading == "Appearance preview");