From a9563828dad7fd9d4843ede6de9cb9f5b9610d33 Mon Sep 17 00:00:00 2001 From: chriskinal <118364224+chriskinal@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:02:06 -0500 Subject: [PATCH] Field close: drop coverage and tram lines from the map Closing a field cleared the coverage service and its bounds on the host, but without a grid the broadcaster skipped its coverage block entirely, so no clear ever reached the clients: they kept drawing the closed field's raster and perimeter until the next field's init. The broadcaster now sends an empty reset init when the grid goes away and the client drops both layers. Tram lines were cleared on field open but not on close, so the old lines (and the field's tram systems, already saved) lingered on the map. ClearFieldState clears them too. Co-Authored-By: Claude Fable 5.1 --- .../AgOpenWeb.RemoteServer/MapBroadcaster.cs | 11 +++++++++++ Shared/AgOpenWeb.RemoteServer/WireCodec.cs | 5 +++++ Shared/AgOpenWeb.RemoteServer/wwwroot/app.js | 10 ++++++++++ Shared/AgOpenWeb.ViewModels/MainViewModel.cs | 6 ++++++ .../FlagPersistenceTests.cs | 19 +++++++++++++++++++ .../MainViewModelBuilder.cs | 3 ++- 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs b/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs index 5e98dd24..54be74e0 100644 --- a/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs +++ b/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs @@ -311,6 +311,17 @@ await _ws.BroadcastAsync(WireCodec.EncodeTick(_projector.BuildTick(_sceneVersion await _ws.BroadcastAsync(WireCodec.EncodeCoverageCells(delta), ct).ConfigureAwait(false); } } + else if (_coverageInitSent && !_snapshotInFlight) + { + // The grid is gone (field closed → ClearFieldBounds). Nothing above runs + // without a grid, so say so explicitly or the clients keep drawing the + // closed field's coverage and perimeter until the next field's init. + _coverageInitSent = false; + _coverageReload = false; + _lastCellSize = 0; + _coverageProjector.ResetSent(); + await _ws.BroadcastAsync(WireCodec.EncodeCoverageClear(), ct).ConfigureAwait(false); + } } catch { /* tolerate transient coverage-layer races */ } diff --git a/Shared/AgOpenWeb.RemoteServer/WireCodec.cs b/Shared/AgOpenWeb.RemoteServer/WireCodec.cs index f24437db..65ce3b09 100644 --- a/Shared/AgOpenWeb.RemoteServer/WireCodec.cs +++ b/Shared/AgOpenWeb.RemoteServer/WireCodec.cs @@ -803,6 +803,11 @@ public static byte[] EncodeCoverageInit(CoverageInitDto c, bool reset) return ms.ToArray(); } + /// No coverage grid any more (field closed): a reset init with an empty grid. + /// The client drops its raster and perimeter (transport.js / app.js onCoverageInit). + public static byte[] EncodeCoverageClear() => + EncodeCoverageInit(new CoverageInitDto(0, 0, 0, 0, 0), reset: true); + public static byte[] EncodeCoverageCells(CoverageCellsDto c) { var cells = c.Cells; diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js index 53435d32..0d2f3efd 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js @@ -376,6 +376,16 @@ const transport = RemoteTransport.create({ }, onCoverageInit(init) { const cs = init.cellSize; + // An empty reset grid means the field closed: drop the raster and the perimeter. + if (init.reset && (init.width <= 0 || init.height <= 0)) { + if (cov) { + if (cov.skImg) cov.skImg.delete(); + if (cov.surface) cov.surface.delete(); + if (cov.covPaint) cov.covPaint.delete(); + } + cov = null; covCells = 0; coverageEdges = null; + return; + } // Grid GROWTH at the same cell size (host bounds-expansion, reset=false): the added ground is // empty, so the host resends nothing. Keep the coverage we already have, re-anchor it into the // larger grid, and carry on with incremental deltas — no rescan, no repaint, no flicker. A diff --git a/Shared/AgOpenWeb.ViewModels/MainViewModel.cs b/Shared/AgOpenWeb.ViewModels/MainViewModel.cs index 6712bb0a..d39d3683 100644 --- a/Shared/AgOpenWeb.ViewModels/MainViewModel.cs +++ b/Shared/AgOpenWeb.ViewModels/MainViewModel.cs @@ -2084,6 +2084,12 @@ private void ClearFieldState() // Clear U-turn state ClearYouTurnState(); + // Clear tram lines and the field's tram systems (saved above). Field open clears them + // too, but between close and the next open the map was still drawing the old lines. + _tramLineService.Clear(); + _tramSystemLineRanges.Clear(); + ConfigStore.Tram.Systems.Clear(); + // Clear flags (without saving — IsFieldOpen is already false) so they don't carry // over into the next field (#107) LoadFlagsFromField(null); diff --git a/Tests/AgOpenWeb.ViewModels.Tests/FlagPersistenceTests.cs b/Tests/AgOpenWeb.ViewModels.Tests/FlagPersistenceTests.cs index 9bda6fa0..21310ebc 100644 --- a/Tests/AgOpenWeb.ViewModels.Tests/FlagPersistenceTests.cs +++ b/Tests/AgOpenWeb.ViewModels.Tests/FlagPersistenceTests.cs @@ -71,4 +71,23 @@ public async Task ClosingTheField_ClearsTheBackgroundImage() Assert.That(vm.State.Field.Imagery, Is.Null, "the web client keeps drawing the picture while the scene carries it"); } + + [Test] + public async Task ClosingTheField_ClearsCoverageAndTramLines() + { + var builder = new MainViewModelBuilder(); + builder.FieldService.ActiveField.Returns(new Field { Name = "F", DirectoryPath = _dir }); + var vm = builder.Build(); + vm.IsFieldOpen = true; + var tram = AgOpenWeb.Models.Configuration.ConfigurationStore.Instance.Tram; + tram.Systems.Clear(); + tram.Systems.Add(new AgOpenWeb.Models.Tram.TramSystem { Name = "T" }); + + await vm.CloseFieldAsync(); + + builder.CoverageMapService.Received().ClearAll(); + builder.CoverageMapService.Received().ClearFieldBounds(); + builder.TramLineService.Received().Clear(); + Assert.That(tram.Systems, Is.Empty, "tram systems are field-scoped; they were saved before the clear"); + } } diff --git a/Tests/AgOpenWeb.ViewModels.Tests/MainViewModelBuilder.cs b/Tests/AgOpenWeb.ViewModels.Tests/MainViewModelBuilder.cs index 55dc9962..715efaaa 100644 --- a/Tests/AgOpenWeb.ViewModels.Tests/MainViewModelBuilder.cs +++ b/Tests/AgOpenWeb.ViewModels.Tests/MainViewModelBuilder.cs @@ -34,6 +34,7 @@ public class MainViewModelBuilder public IModuleCommunicationService ModuleCommunicationService { get; set; } = Substitute.For(); public IConfigurationService ConfigurationService { get; set; } = Substitute.For(); public IFieldService FieldService { get; } = Substitute.For(); + public AgOpenWeb.Services.Interfaces.ITramLineService TramLineService { get; } = Substitute.For(); public AgOpenWeb.Services.Pipeline.PipelineIntents Intents { get; } = new(); public MainViewModelBuilder() @@ -87,7 +88,7 @@ public MainViewModel Build() audioService: Substitute.For(), elevationLogService: Substitute.For(), jobService: Substitute.For(), - tramLineService: Substitute.For(), + tramLineService: TramLineService, gpsPipelineService: GpsPipelineService, intents: Intents, logger: NullLogger.Instance,