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 @@ - + + + + + + + + + + + + -