From 56cb53a5057ce9839afa9210a4f560a14ae4f628 Mon Sep 17 00:00:00 2001 From: chriskinal <118364224+chriskinal@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:26:39 -0500 Subject: [PATCH 1/5] Fix camera/heading mode buttons not responding on the launcher (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camera-panel buttons (mode/heading cycle, zoom, tilt) were bound on `click`, which the NativeWebView launcher (touch) doesn't fire reliably — so tapping the heading-mode button did nothing on the launcher build while it worked in a browser. Bind them on `pointerdown` instead (the same event the rest of the touch UI uses); the campad container already stops propagation so this doesn't pan the map. Fixes #56. Co-Authored-By: Claude Opus 4.8 (1M context) --- Shared/AgOpenWeb.RemoteServer/wwwroot/app.js | 14 +++++++++----- sys/version.h | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js index 4791c426..cbab6e52 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js @@ -3861,14 +3861,18 @@ function renderCampad() { const pad = document.getElementById('campad'); if (!pad) return; pad.addEventListener('pointerdown', e => e.stopPropagation()); // don't pan the map - document.getElementById('cp-tiltup').addEventListener('click', () => { pitch = Math.max(0, pitch - PITCH_STEP); }); - document.getElementById('cp-tiltdown').addEventListener('click', () => { + // Bind on POINTERDOWN, not click: the NativeWebView launcher (touch) doesn't reliably fire + // `click`, so these camera buttons — notably the mode/heading cycle — did nothing when tapped + // on the launcher build (issue #56). pointerdown is the same event the rest of the touch UI + // uses; the pad's stopPropagation above already keeps it from panning the map. + document.getElementById('cp-tiltup').addEventListener('pointerdown', () => { pitch = Math.max(0, pitch - PITCH_STEP); }); + document.getElementById('cp-tiltdown').addEventListener('pointerdown', () => { pitch = Math.min(MAX_PITCH, pitch + PITCH_STEP); }); - document.getElementById('cp-zoomin').addEventListener('click', () => { pxPerM = Math.min(200, pxPerM * 1.2); }); - document.getElementById('cp-zoomout').addEventListener('click', () => { pxPerM = Math.max(0.2, pxPerM * 0.83); }); + document.getElementById('cp-zoomin').addEventListener('pointerdown', () => { pxPerM = Math.min(200, pxPerM * 1.2); }); + document.getElementById('cp-zoomout').addEventListener('pointerdown', () => { pxPerM = Math.max(0.2, pxPerM * 0.83); }); // Center: cycle the four native modes H → N → M → C → H; recenter on follow modes. - document.getElementById('cp-mode').addEventListener('click', () => { + document.getElementById('cp-mode').addEventListener('pointerdown', () => { cameraMode = cameraMode === 1 ? 0 : cameraMode === 0 ? 3 : cameraMode === 3 ? 2 : 1; if (cameraMode !== 2) { const rp = renderPose(); if (rp) { camE = rp.e; camN = rp.n; } } }); diff --git a/sys/version.h b/sys/version.h index d9b07ac8..95ff9e20 100644 --- a/sys/version.h +++ b/sys/version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 26 #define VERSION_MINOR 6 -#define VERSION_PATCH 69 +#define VERSION_PATCH 70 -#define VERSION "26.6.69" +#define VERSION "26.6.70" #define VERSION_DATE "2026-07-02" From 29d70b89aabe52c15932e06c9e62a4106f6576f7 Mon Sep 17 00:00:00 2001 From: chriskinal <118364224+chriskinal@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:44:10 -0500 Subject: [PATCH 2/5] Fix alert-sound burst on first UI click after startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audio-unlock nudge played every cached alert element to prime it for autoplay, pausing each in an async .then(). Because the pause lands a beat after play() starts, the first pointer/key event emitted a brief burst of all alerts — most audibly the Hyd up/down 3-pt-hitch sounds. Prime the elements MUTED instead (muted playback still satisfies the autoplay policy), then restore. Real plays clone the element so they're unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- Shared/AgOpenWeb.RemoteServer/wwwroot/app.js | 9 +++++++-- sys/version.h | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js index cbab6e52..52b0845a 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js @@ -356,9 +356,14 @@ const Sounds = (() => { if (unlocked) return; unlocked = true; // Nudge each element into a "played once" state so later programmatic play() - // (not tied to a gesture) is allowed by the autoplay policy. + // (not tied to a gesture) is allowed by the autoplay policy. Do it MUTED: the pause + // lands asynchronously in .then(), so an unmuted prime plays a burst of every alert + // audibly on the first UI click — notably the Hyd up/down 3-pt-hitch sounds. Muted + // playback still primes the element (and is always autoplay-allowed). Real plays clone + // the element, so they're unaffected by this temporary mute; restore it afterward anyway. for (const a of cache.values()) { - a.play().then(() => { a.pause(); a.currentTime = 0; }).catch(() => {}); + a.muted = true; + a.play().then(() => { a.pause(); a.currentTime = 0; a.muted = false; }).catch(() => { a.muted = false; }); } window.removeEventListener('pointerdown', unlock); window.removeEventListener('keydown', unlock); diff --git a/sys/version.h b/sys/version.h index 95ff9e20..c41e71ff 100644 --- a/sys/version.h +++ b/sys/version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 26 #define VERSION_MINOR 6 -#define VERSION_PATCH 70 +#define VERSION_PATCH 71 -#define VERSION "26.6.70" +#define VERSION "26.6.71" #define VERSION_DATE "2026-07-02" From 07fc89a183b039a490f21e64b71207beae31e54c Mon Sep 17 00:00:00 2001 From: Xyntexx <21282105+Xyntexx@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:46:32 +0300 Subject: [PATCH 3/5] Fix file-lock failure in Load_OlderProfileWithoutNewFields test The test opened a read handle (FileShare.Read) on OldProfile.json and, while that handle was still open inside the using block, called WriteAllText on the same path -- which deterministically failed with 'process cannot access the file'. Read the file fully into a string and close the handle before writing the stripped-down profile back. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ProfileJsonServiceV1Tests.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs b/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs index 66d7b9f4..ca9ce431 100644 --- a/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs +++ b/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs @@ -274,16 +274,14 @@ public void Load_OlderProfileWithoutNewFields_KeepsModelDefaults() // Reparse, remove the new fields properly, write back. Mirrors what an // older app build's saved profile would look like. - using (var src = System.IO.File.OpenRead(path)) - { - var node = System.Text.Json.Nodes.JsonNode.Parse(src)!; - var toolObj = node["tool"]!.AsObject(); - foreach (var k in droppedToolKeys) toolObj.Remove(k); - var guidanceObj = node["guidance"]!.AsObject(); - foreach (var k in droppedGuidanceKeys) guidanceObj.Remove(k); - System.IO.File.WriteAllText(path, node.ToJsonString( - new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); - } + var node = System.Text.Json.Nodes.JsonNode.Parse( + System.IO.File.ReadAllText(path))!; + var toolObj = node["tool"]!.AsObject(); + foreach (var k in droppedToolKeys) toolObj.Remove(k); + var guidanceObj = node["guidance"]!.AsObject(); + foreach (var k in droppedGuidanceKeys) guidanceObj.Remove(k); + System.IO.File.WriteAllText(path, node.ToJsonString( + new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); var loadStore = new ConfigurationStore(); ProfileJsonServiceV1.Load(_tempDir, "OldProfile", loadStore); From 75b17c94b87a42d671a060f7396fcf79f801952c Mon Sep 17 00:00:00 2001 From: Xyntexx <21282105+Xyntexx@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:55:34 +0300 Subject: [PATCH 4/5] Bind virtual test modules to loopback to avoid Windows firewall prompt The virtual steer and machine modules bound their listen sockets to IPAddress.Any (0.0.0.0), which makes Windows Defender Firewall prompt for network access on every test run. All virtual-module traffic is 127.0.0.1, so bind the listeners to IPAddress.Loopback instead -- loopback traffic bypasses the firewall entirely, no per-machine allow rule needed. The send-only GPS receiver is left as an unbound UdpClient: outbound UDP does not trigger the prompt, and eager binding would race the machine module for an ephemeral port in VirtualModuleHub. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../VirtualModules/VirtualGpsReceiver.cs | 4 ++++ .../VirtualModules/VirtualMachineModule.cs | 4 +++- .../VirtualModules/VirtualSteerModule.cs | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualGpsReceiver.cs b/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualGpsReceiver.cs index 10012b2c..6b9e8142 100644 --- a/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualGpsReceiver.cs +++ b/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualGpsReceiver.cs @@ -55,6 +55,10 @@ public class VirtualGpsReceiver : IDisposable public VirtualGpsReceiver(int targetPort = 9999, string targetIp = "127.0.0.1") { + // Send-only socket; leave it unbound so it lazily grabs an ephemeral port + // on first send. Outbound UDP does not trigger the Windows firewall prompt + // (only the listening modules do), and eager binding here would race the + // machine module for an ephemeral port in VirtualModuleHub. _udp = new UdpClient(); _target = new IPEndPoint(IPAddress.Parse(targetIp), targetPort); } diff --git a/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualMachineModule.cs b/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualMachineModule.cs index 80106270..e7ce1e5e 100644 --- a/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualMachineModule.cs +++ b/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualMachineModule.cs @@ -52,7 +52,9 @@ public class VirtualMachineModule : IDisposable public VirtualMachineModule(int listenPort = 8888, int hostPort = 9999, string hostIp = "127.0.0.1") { - _udp = new UdpClient(new IPEndPoint(IPAddress.Any, listenPort)); + // Bind to loopback (not IPAddress.Any) so Windows Defender Firewall does + // not prompt during test runs. All virtual-module traffic is 127.0.0.1. + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, listenPort)); _hostEndpoint = new IPEndPoint(IPAddress.Parse(hostIp), hostPort); } diff --git a/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualSteerModule.cs b/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualSteerModule.cs index d4903ebc..dfceb554 100644 --- a/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualSteerModule.cs +++ b/Tests/AgOpenWeb.IntegrationTests/VirtualModules/VirtualSteerModule.cs @@ -128,7 +128,9 @@ public class VirtualSteerModule : IDisposable public VirtualSteerModule(int listenPort = 8888, int hostPort = 9999, string hostIp = "127.0.0.1") { - _udp = new UdpClient(new IPEndPoint(IPAddress.Any, listenPort)); + // Bind to loopback (not IPAddress.Any) so Windows Defender Firewall does + // not prompt during test runs. All virtual-module traffic is 127.0.0.1. + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, listenPort)); _hostEndpoint = new IPEndPoint(IPAddress.Parse(hostIp), hostPort); } From a49548365ccad4b672ae54470b4ea748aedf6000 Mon Sep 17 00:00:00 2001 From: chriskinal <118364224+chriskinal@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:13:42 -0500 Subject: [PATCH 5/5] Fix periodic coverage-map freeze on large fields (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web UI froze for ~1s and jumped the tractor forward whenever the coverage grid changed, worsening with field size. Root causes + fixes, all in the coverage streaming path: - Full-coverage snapshot walked the ENTIRE field bounding box at 0.1m (O(field area), ~130ms on a 40ha field even with 0.63ha painted) on the 10Hz broadcast loop, stalling pose/tick frames. Now enumerates only painted display cells via GetPaintedDisplayCells (O(worked area), ~9ms) and builds off the broadcast loop so pose never stalls. - Client drained a full snapshot in one render frame (unbounded per-cell drawRect). Now time-sliced (COV_DRAIN_BUDGET/frame). - Bounds expansion re-sent the entire accumulated coverage. The added ground is empty, so the host now announces the new grid (reset=false) and the client re-anchors its existing coverage into the larger grid (exact integer-offset pixel copy) — zero rescan, zero resend. A new CoverageInit `reset` flag distinguishes grow (re-anchor) from new field / reload / cell-size change (tear down + rebuild). - The first no-boundary expansion crosses the 0.1->0.2m resolution band (rebuild path). GetPaintedDisplayCells read the display buffer lock-free and could catch it cleared-but-not-yet-resampled, yielding an empty snapshot that permanently dropped pre-expansion coverage. Now materialized under _coverageLock (alpha folded in) for a consistent read. - Pre-existing edge-ribbon bug surfaced by the no-boundary test path: the auto-init SetFieldBounds->ClearEdges wiped an active section's ribbon while it stayed on, so StartMapping never re-opened it and the crisp worked-area perimeter silently never rendered. AccumulateEdge now re-opens a ribbon on a miss (self-heals). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CoverageProjector.cs | 16 ++- .../AgOpenWeb.RemoteServer/MapBroadcaster.cs | 98 +++++++++++--- Shared/AgOpenWeb.RemoteServer/WireCodec.cs | 6 +- Shared/AgOpenWeb.RemoteServer/wwwroot/app.js | 127 +++++++++++++----- .../wwwroot/transport.js | 2 +- .../Coverage/CoverageMapService.cs | 81 ++++++++++- .../Interfaces/ICoverageMapService.cs | 9 ++ sys/version.h | 6 +- 8 files changed, 286 insertions(+), 59 deletions(-) diff --git a/Shared/AgOpenWeb.RemoteServer/CoverageProjector.cs b/Shared/AgOpenWeb.RemoteServer/CoverageProjector.cs index 05fb97a7..4aecb842 100644 --- a/Shared/AgOpenWeb.RemoteServer/CoverageProjector.cs +++ b/Shared/AgOpenWeb.RemoteServer/CoverageProjector.cs @@ -29,15 +29,17 @@ public sealed class CoverageProjector return new CoverageInitDto(dim.CellSize, bnd.MinE, bnd.MinN, dim.Width, dim.Height); } - /// Full current coverage — for seeding a newly-connected client. Non-draining; ignores the sent bitset. + /// Full current coverage — for seeding a newly-connected client / server re-init. + /// Enumerates ONLY painted display cells (O(painted area), not an O(whole-field) scan) so a + /// large field with little worked ground rebuilds cheaply. Non-draining; ignores the sent bitset. public CoverageCellsDto? Snapshot() { - if (_cov.DisplayDimensions is not { } dim || _cov.DisplayBoundsWorld is not { } bnd) - return null; - var flat = new List(); - foreach (var (x, y, c) in _cov.GetCoverageBitmapCells(dim.CellSize, bnd.MinE, bnd.MaxE, bnd.MinN, bnd.MaxN)) - Add(flat, x, y, c, _cov.GetDisplayCellAlpha255(x, y)); - return flat.Count == 0 ? null : new CoverageCellsDto(flat.ToArray()); + var cells = _cov.GetPaintedDisplayCells(); + if (cells.Count == 0) return null; + var flat = new List(cells.Count * 3); + foreach (var (x, y, c, a) in cells) + Add(flat, x, y, c, a); + return new CoverageCellsDto(flat.ToArray()); } /// diff --git a/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs b/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs index 453bb82d..a081a027 100644 --- a/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs +++ b/Shared/AgOpenWeb.RemoteServer/MapBroadcaster.cs @@ -52,7 +52,15 @@ public sealed class MapBroadcaster : IAsyncDisposable public Func<(double Pitch, double Zoom)?>? ViewPrefsProvider { get; set; } private volatile bool _coverageInitSent; private double _lastCellSize; + // Last coverage grid announced to clients (origin + dims). A change here with the SAME cell + // size is a bounds-EXPANSION: the added ground is empty, so we send a new init only and the + // client re-anchors its existing coverage — no rescan, no resend. A cell-size change or a + // full reload still forces a fresh snapshot. + private double _lastOriginE = double.NaN, _lastOriginN = double.NaN; + private int _lastWidth = -1, _lastHeight = -1; private volatile bool _coverageReload; // set by OnCoverageUpdated on a full reload + private volatile bool _snapshotInFlight; // a full coverage snapshot is building/broadcasting off-loop + private Task? _snapshotTask; public MapBroadcaster(WebSocketHub ws, SceneProjector projector, ICoverageMapService coverage, CoverageProjector coverageProjector, @@ -91,7 +99,7 @@ private IReadOnlyList BuildSeed() frames.Add(WireCodec.EncodeViewPrefs(vp.Pitch, vp.Zoom)); if (_coverageProjector.BuildInit() is { } init) { - frames.Add(WireCodec.EncodeCoverageInit(init)); + frames.Add(WireCodec.EncodeCoverageInit(init, reset: true)); // fresh client → rebuild from the seed snapshot if (_coverageProjector.Snapshot() is { } snap) frames.Add(WireCodec.EncodeCoverageCells(snap)); } @@ -105,12 +113,11 @@ public void Start() _loop ??= Task.Run(() => RunAsync(_cts.Token)); } - // Bounds growth / cell-size rescale changes the grid → clients must re-init. - private void OnBoundsExpanded(object? sender, BoundsExpandedEventArgs e) - { - _coverageInitSent = false; - _coverageProjector.ResetSent(); - } + // Bounds growth / cell-size rescale changes the grid. We DON'T force a full re-init here: + // the broadcast loop notices the grid change by comparing BuildInit() to the last-announced + // grid, and picks re-anchor (grow, same cell size) vs. full snapshot (cell-size change) + // itself. Left as a no-op hook (kept subscribed for clarity / future use). + private void OnBoundsExpanded(object? sender, BoundsExpandedEventArgs e) { } private void OnCoverageUpdated(object? sender, CoverageUpdatedEventArgs e) { @@ -224,25 +231,70 @@ await _ws.BroadcastAsync(WireCodec.EncodeTick(_projector.BuildTick(_sceneVersion .ConfigureAwait(false); // Coverage — diff + broadcast on THIS (broadcaster) thread, NEVER the 100 Hz - // control loop. (Re)init on first coverage / cell-size change / full reload - // sends a full snapshot; steady state drains the server-dedicated incremental - // stream (O(new cells)). This is the 10 Hz coverage cadence. + // control loop. Steady state drains the server-dedicated incremental stream + // (O(new cells)). This is the 10 Hz coverage cadence. Three cases (issue #34): + // + // • Cold start / field reload / cell-size change → a full snapshot is needed + // (the client has nothing usable). Snapshot now enumerates ONLY painted cells + // (CoverageProjector.Snapshot → GetPaintedDisplayCells, O(worked area) — it no + // longer walks the whole field), and it's built + broadcast on a BACKGROUND + // task so the scan can't stall the 10 Hz pose/tick feed (which was the freeze: + // the map stopped, then jumped when it resumed). Per-client send gates + // (WebSocketHub) make the concurrent broadcast safe. Deltas are suspended while + // that snapshot is in flight; we do NOT DiscardIncremental after it, so cells + // laid mid-snapshot ride the resumed delta stream (harmless idempotent repaint, + // client BlendMode.Src, newer alpha wins) rather than being dropped. + // + // • Bounds EXPANSION at the same cell size → the added ground is empty, so the + // host resends NOTHING. It announces the new grid (reset:false) and the client + // re-anchors the coverage it already has; incremental deltas just continue. + // + // • Otherwise → an incremental delta of newly-painted cells. try { if (_coverageProjector.BuildInit() is { } cvInit) { - if (!_coverageInitSent || cvInit.CellSize != _lastCellSize || _coverageReload) + bool coldOrReload = !_coverageInitSent || _coverageReload; + bool cellSizeChanged = _coverageInitSent && cvInit.CellSize != _lastCellSize; + bool gridGrew = _coverageInitSent && !cellSizeChanged && + (cvInit.OriginE != _lastOriginE || cvInit.OriginN != _lastOriginN || + cvInit.Width != _lastWidth || cvInit.Height != _lastHeight); + + if ((coldOrReload || cellSizeChanged) && !_snapshotInFlight) { + // Cold start / field reload / cell-size change: the client has nothing + // usable, so send a fresh init + full snapshot (built off-loop). Snapshot + // now enumerates only painted cells (O(worked area)), not the whole field. _coverageReload = false; _coverageInitSent = true; _lastCellSize = cvInit.CellSize; + RecordGrid(cvInit); + _snapshotInFlight = true; _coverageProjector.ResetSent(); - await _ws.BroadcastAsync(WireCodec.EncodeCoverageInit(cvInit), ct).ConfigureAwait(false); - if (_coverageProjector.Snapshot() is { } full) - await _ws.BroadcastAsync(WireCodec.EncodeCoverageCells(full), ct).ConfigureAwait(false); - _coverageProjector.DiscardIncremental(); // snapshot already covers everything so far + _snapshotTask = Task.Run(async () => + { + try + { + await _ws.BroadcastAsync(WireCodec.EncodeCoverageInit(cvInit, reset: true), ct).ConfigureAwait(false); + if (_coverageProjector.Snapshot() is { } full) + await _ws.BroadcastAsync(WireCodec.EncodeCoverageCells(full), ct).ConfigureAwait(false); + } + catch { /* tolerate transient coverage-layer races / cancellation */ } + finally { _snapshotInFlight = false; } + }, ct); + } + else if (gridGrew && !_snapshotInFlight) + { + // Bounds EXPANSION at the same cell size: the added ground is empty, so + // nothing is rescanned or resent. Announce the new grid; the client + // re-anchors the coverage it already has. Incremental deltas (already in + // new-grid coords) continue immediately. + RecordGrid(cvInit); + await _ws.BroadcastAsync(WireCodec.EncodeCoverageInit(cvInit, reset: false), ct).ConfigureAwait(false); + if (_coverageProjector.IncrementalDelta() is { } grow) + await _ws.BroadcastAsync(WireCodec.EncodeCoverageCells(grow), ct).ConfigureAwait(false); } - else if (_coverageProjector.IncrementalDelta() is { } delta) + else if (!_snapshotInFlight && _coverageProjector.IncrementalDelta() is { } delta) { await _ws.BroadcastAsync(WireCodec.EncodeCoverageCells(delta), ct).ConfigureAwait(false); } @@ -284,6 +336,16 @@ await _ws.BroadcastAsync(WireCodec.EncodeStatus(_projector.BuildStatus()), ct) } } + // Remember the coverage grid we last announced, so the next tick can tell a bounds-expansion + // (same cell size → re-anchor) from a cell-size change (→ fresh snapshot). + private void RecordGrid(CoverageInitDto init) + { + _lastOriginE = init.OriginE; + _lastOriginN = init.OriginN; + _lastWidth = init.Width; + _lastHeight = init.Height; + } + private static readonly RecordedPathDto EmptyRecordedPath = new(Array.Empty(), false, false, false, "", "Start", "", Array.Empty()); @@ -342,6 +404,10 @@ public async ValueTask DisposeAsync() { try { await _loop.ConfigureAwait(false); } catch { /* ignore */ } } + if (_snapshotTask is not null) + { + try { await _snapshotTask.ConfigureAwait(false); } catch { /* ignore */ } + } _cts.Dispose(); } } diff --git a/Shared/AgOpenWeb.RemoteServer/WireCodec.cs b/Shared/AgOpenWeb.RemoteServer/WireCodec.cs index 9219daa4..ecc62575 100644 --- a/Shared/AgOpenWeb.RemoteServer/WireCodec.cs +++ b/Shared/AgOpenWeb.RemoteServer/WireCodec.cs @@ -648,7 +648,10 @@ public static byte[] EncodeControlState(ControlStateDto s) return ms.ToArray(); } - public static byte[] EncodeCoverageInit(CoverageInitDto c) + // reset=true → the client must drop any coverage it has and rebuild from the snapshot that + // follows (cold start / field reload / cell-size change). reset=false → the grid merely grew + // (bounds expansion); the client re-anchors its existing coverage and no snapshot follows. + public static byte[] EncodeCoverageInit(CoverageInitDto c, bool reset) { using var ms = new MemoryStream(); using var w = new BinaryWriter(ms); @@ -658,6 +661,7 @@ public static byte[] EncodeCoverageInit(CoverageInitDto c) w.Write(c.OriginN); // f64 w.Write(c.Width); // i32 w.Write(c.Height); // i32 + w.Write(reset); // u8 (0/1) return ms.ToArray(); } diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js index 52b0845a..82881735 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/app.js @@ -47,6 +47,13 @@ const POSE_BUF_MAX = 12; // ~1.2 s @ 10 Hz — plenty of bracket for the delay + // drain the buffer are bridged by EXTRAPOLATION (sample(), below) rather than a freeze, so // we get smoothness without the lag. ~130 ms covers the pose interval + interpolation room. const RENDER_DELAY = 130; // ms +// Max coverage cells rasterized per frame. A full-coverage snapshot (sent by the host on +// grid bounds-expansion / reconnect / field reload) can be hundreds of thousands of cells; +// draining it all in one frame is a multi-hundred-ms main-thread block that froze the map +// then jumped the tractor forward (issue #34). We instead drain up to this many cells per +// frame and resume the rest next frame (progress stored on batch.off), so a big snapshot +// paints over a handful of frames with no visible hitch. ~40k rects ≈ 1-2 ms/frame. +const COV_DRAIN_BUDGET = 40000; // Max time we'll extrapolate past the newest pose when the buffer underruns (WiFi spike / // dropped pose) before settling to a hold — bridges spikes without flying off on a real // dropout. At field speed 200 ms is well under a pose-pair's worth of travel. @@ -248,34 +255,24 @@ const transport = RemoteTransport.create({ pushChartData(t); }, onCoverageInit(init) { - if (cov) { // tear down a prior coverage (field/resolution change) + const cs = init.cellSize; + // 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 + // reset init (new field / reload / cell-size change) always falls through to a clean rebuild. + if (!init.reset && cov && Math.abs(cov.cellSize - cs) < 1e-9 && + (init.width !== cov.width || init.height !== cov.height || + Math.abs(init.originE - cov.originE) > 1e-9 || Math.abs(init.originN - cov.originN) > 1e-9)) { + reanchorCoverage(init); + return; + } + // Fresh field / resolution (cell-size) change: tear down and rebuild. A full snapshot follows. + if (cov) { if (cov.skImg) cov.skImg.delete(); if (cov.surface) cov.surface.delete(); if (cov.covPaint) cov.covPaint.delete(); } - const w = init.width, h = init.height; - // Primary: a persistent GPU render target. New cells are drawn straight onto it each - // update (cheap, only the new cells) and snapshotted (texture copy-on-write) — NO - // whole-texture re-upload, which was the regular per-rebuild stutter. Fallback: the - // offscreen 2D canvas + full re-upload (throttled) if the render target can't be made. - let surface = null; - if (CK && grCtx) { try { surface = CK.MakeRenderTarget(grCtx, w, h); } catch (e) { surface = null; } } - let canvas = null, cctx = null, covPaint = null; - if (surface) { - surface.getCanvas().clear(CK.TRANSPARENT); - covPaint = new CK.Paint(); covPaint.setStyle(CK.PaintStyle.Fill); covPaint.setAntiAlias(false); - // Src (replace), not SrcOver: cells carry a coverage-fraction alpha (edge cells < 1), - // and a cell is re-emitted with rising alpha as it fills — replace sets the exact value - // each time; SrcOver would blend re-draws and creep edges toward opaque. - covPaint.setBlendMode(CK.BlendMode.Src); - } else { - canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; cctx = canvas.getContext('2d'); - } - cov = { - cellSize: init.cellSize, originE: init.originE, originN: init.originN, - width: w, height: h, surface, covPaint, pending: [], - canvas, cctx, dirty: true, skImg: null, lastBuild: 0, - }; + cov = makeCovGrid(init); covCells = 0; }, onCoverageCells(msg) { @@ -4863,6 +4860,72 @@ function drawSatelliteSk(canvas) { // Fallback-path throttle (2D canvas → full re-upload): cap the expensive whole-texture // rebuild at ~4 Hz. The GPU render-target path below has no such cost. const COV_REBUILD_MS = 250; +// Build a fresh coverage grid for a CoverageInit. Primary: a persistent GPU render target (new +// cells drawn straight onto it, snapshotted via texture copy-on-write — no whole-texture +// re-upload). Fallback: an offscreen 2D canvas + throttled re-upload if the target can't be made. +function makeCovGrid(init) { + const w = init.width, h = init.height; + let surface = null; + if (CK && grCtx) { try { surface = CK.MakeRenderTarget(grCtx, w, h); } catch (e) { surface = null; } } + let canvas = null, cctx = null, covPaint = null; + if (surface) { + surface.getCanvas().clear(CK.TRANSPARENT); + covPaint = new CK.Paint(); covPaint.setStyle(CK.PaintStyle.Fill); covPaint.setAntiAlias(false); + // Src (replace), not SrcOver: cells carry a coverage-fraction alpha (edge cells < 1), and a + // cell is re-emitted with rising alpha as it fills — replace sets the exact value each time; + // SrcOver would blend re-draws and creep edges toward opaque. + covPaint.setBlendMode(CK.BlendMode.Src); + } else { + canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; cctx = canvas.getContext('2d'); + } + return { + cellSize: init.cellSize, originE: init.originE, originN: init.originN, + width: w, height: h, surface, covPaint, pending: [], + canvas, cctx, dirty: true, skImg: null, lastBuild: 0, + }; +} + +// Re-anchor existing coverage into a grown grid (bounds expansion, SAME cell size). The old +// painted surface is copied into the new, larger surface at the integer cell offset between the +// grids — exact, because the host shifts the grid by a whole number of cells — accounting for the +// render y-flip (surface row 0 = highest northing). Not-yet-rasterized delta batches are shifted +// into the new coords too. Nothing is re-sent by the host; the newly-added ground is empty. +function reanchorCoverage(init) { + const cs = init.cellSize; + const oldCov = cov; + const offX = Math.round((oldCov.originE - init.originE) / cs); + const offY = Math.round((oldCov.originN - init.originN) / cs); + const nc = makeCovGrid(init); + const destX = offX; + const destY = (nc.height - oldCov.height) - offY; // y-flip: old image sits (maxN growth) below the top + + if (oldCov.surface && nc.surface) { + oldCov.surface.flush(); + const img = oldCov.surface.makeImageSnapshot(); + // Nearest + integer offset = exact pixel copy (no resample). + nc.surface.getCanvas().drawImageOptions(img, destX, destY, CK.FilterMode.Nearest, CK.MipmapMode.None, null); + img.delete(); + nc.surface.flush(); + } else if (oldCov.canvas && nc.canvas) { + nc.cctx.drawImage(oldCov.canvas, destX, destY); + } + // else: render-target mode changed across the grow (rare) — old coverage isn't carried; deltas + // repaint going forward and the next full reload/reconnect reseeds. No crash, no misplacement. + nc.dirty = true; + + // Carry queued (not-yet-drained) delta batches, shifted into the new grid coords. + for (const b of oldCov.pending) { + const c = b.cells; + for (let i = 0; i + 2 < c.length; i += 3) { c[i] += offX; c[i + 1] += offY; } + nc.pending.push(b); + } + + if (oldCov.skImg) oldCov.skImg.delete(); + if (oldCov.surface) oldCov.surface.delete(); + if (oldCov.covPaint) oldCov.covPaint.delete(); + cov = nc; +} + function drawCoverageSk(canvas) { if (!cov) return; // Only rasterize batches that have aged past RENDER_DELAY, so coverage lands on the same @@ -4874,16 +4937,18 @@ function drawCoverageSk(canvas) { // texture COW — cheap). No whole-texture upload, so no regular per-rebuild stutter. if (cov.pending.length && cov.pending[0].t <= cutoff) { const sk = cov.surface.getCanvas(), paint = cov.covPaint, H = cov.height; - let lastRgb = -1, drained = 0; + let lastRgb = -1, drained = 0, budget = COV_DRAIN_BUDGET; for (const batch of cov.pending) { if (batch.t > cutoff) break; const c = batch.cells; - for (let i = 0; i + 2 < c.length; i += 3) { + let i = batch.off || 0; // resume a batch we ran out of budget on last frame + for (; i + 2 < c.length && budget > 0; i += 3) { const x = c[i], y = c[i + 1], v = c[i + 2] >>> 0; // (a<<24)|(r<<16)|(g<<8)|b if (v !== lastRgb) { paint.setColor(CK.Color((v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF, ((v >>> 24) & 0xFF) / 255)); lastRgb = v; covEdgeRgb = v & 0xFFFFFF; } sk.drawRect(CK.XYWHRect(x, H - 1 - y, 1, 1), paint); // flip: high northing at top - covCells++; + covCells++; budget--; } + if (i + 2 < c.length) { batch.off = i; break; } // budget hit mid-batch; finish next frame drained++; } cov.pending.splice(0, drained); @@ -4900,11 +4965,12 @@ function drawCoverageSk(canvas) { // 2D fallback: drain aged batches into the offscreen canvas (same RENDER_DELAY gate). if (cov.pending.length && cov.pending[0].t <= cutoff) { const H = cov.height, cctx = cov.cctx; - let lastRgb = -1, drained = 0; + let lastRgb = -1, drained = 0, budget = COV_DRAIN_BUDGET; for (const batch of cov.pending) { if (batch.t > cutoff) break; const c = batch.cells; - for (let i = 0; i + 2 < c.length; i += 3) { + let i = batch.off || 0; // resume a batch we ran out of budget on last frame + for (; i + 2 < c.length && budget > 0; i += 3) { const x = c[i], y = c[i + 1], v = c[i + 2] >>> 0; // (a<<24)|(r<<16)|(g<<8)|b if (v !== lastRgb) { cctx.fillStyle = 'rgba(' + ((v >> 16) & 0xFF) + ',' + ((v >> 8) & 0xFF) + ',' + (v & 0xFF) + ',' + (((v >>> 24) & 0xFF) / 255) + ')'; @@ -4914,8 +4980,9 @@ function drawCoverageSk(canvas) { // cells set their exact alpha instead of blending toward opaque. cctx.clearRect(x, H - 1 - y, 1, 1); cctx.fillRect(x, H - 1 - y, 1, 1); // flip: high northing at offscreen top - covCells++; + covCells++; budget--; } + if (i + 2 < c.length) { batch.off = i; break; } // budget hit mid-batch; finish next frame drained++; } cov.pending.splice(0, drained); diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js index e588c71c..6f2dfb30 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js @@ -323,7 +323,7 @@ window.RemoteTransport = { } case TYPE.COVERAGE_INIT: { handlers.onCoverageInit && handlers.onCoverageInit({ - cellSize: f64(), originE: f64(), originN: f64(), width: i32(), height: i32(), + cellSize: f64(), originE: f64(), originN: f64(), width: i32(), height: i32(), reset: !!u8(), }); break; } diff --git a/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs b/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs index 2694e8bd..ece8edd5 100644 --- a/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs +++ b/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs @@ -345,10 +345,27 @@ private void AddCoveragePointCore(int zoneIndex, Vec2 leftEdge, Vec2 rightEdge) } // Append a swept edge pair to the zone's ribbon, distance-gated. Caller holds _coverageLock. + // Only reached for an actively-mapping zone (AddCoveragePoint gates on _activeSections). private void AccumulateEdge(int zoneIndex, Vec2 leftEdge, Vec2 rightEdge) { if (_edgePointCount >= EDGE_MAX_POINTS) return; - if (!_edgeRunByZone.TryGetValue(zoneIndex, out var run)) return; + if (!_edgeRunByZone.TryGetValue(zoneIndex, out var run)) + { + // The zone is actively mapping but has no open ribbon. This happens when ClearEdges + // ran while the section stayed on — notably the no-boundary path, where the coverage + // grid auto-inits (SetFieldBounds → ClearEdges) AFTER mapping started, wiping the + // ribbon that StartMapping opened. StartMapping won't re-open it (it only fires on the + // off→on edge), so the crisp worked-area perimeter would silently never appear for the + // rest of the run. Re-open the ribbon here so the perimeter keeps building. + run = new EdgeRun(); + run.Left.Add(leftEdge); run.Right.Add(rightEdge); + _edgeRunByZone[zoneIndex] = run; + _edgeRuns.Add(run); + _edgeGateLast[zoneIndex] = ((leftEdge.Easting + rightEdge.Easting) * 0.5, + (leftEdge.Northing + rightEdge.Northing) * 0.5); + _edgePointCount++; + return; + } double cx = (leftEdge.Easting + rightEdge.Easting) * 0.5; double cy = (leftEdge.Northing + rightEdge.Northing) * 0.5; if (_edgeGateLast.TryGetValue(zoneIndex, out var last)) @@ -965,6 +982,57 @@ public long GetTotalCellCount() } } + /// + /// Enumerate ONLY the painted display cells, for the full-coverage snapshot (server + /// re-init / new-client seed). Reads the RGB565 display buffer directly, walking just the + /// tracked painted bounding box (_minCellE.._maxCellN) — so cost is O(painted area), never + /// the O(whole-field 0.1 m) walk that does. That scan + /// touched every cell in the field (tens of millions on a large field) even to find a tiny + /// worked strip, which stalled the coverage rebuild; this touches only ground that has + /// actually been worked. Output cells are in display coordinates (x = col, y = row from the + /// SW origin), matching the CoverageInit grid the client renders into. Lock-free read (same + /// as the other emit scans); a race yields a slightly stale cell, which is harmless. + /// + public IReadOnlyList<(int X, int Y, CoverageColor Color, int Alpha)> GetPaintedDisplayCells() + { + var result = new List<(int, int, CoverageColor, int)>(); + // MUST hold _coverageLock: a bounds-expansion on the control thread clears the display + // buffer in SetFieldBounds and then spends tens of ms resampling the old pixels back in + // (CheckAndExpandBounds) — all under this lock. A lock-free read here could catch the + // buffer cleared-but-not-yet-refilled and return an EMPTY snapshot, which (with the client + // tearing down on a reset init and re-anchor no longer resending) permanently dropped the + // pre-expansion coverage on the first cell-size-change expansion. Locking waits for the + // fully-resampled buffer. Alpha is folded in here too so it's consistent with the cells. + lock (_coverageLock) + { + var buf = _displayPixels; + int w = _displayWidth, h = _displayHeight; + if (buf == null || !_fieldBoundsSet || !_boundsValid || w <= 0 || h <= 0) + return result; + double cell = _displayCellSize, fMinE = _fieldMinE, fMinN = _fieldMinN; + + // Painted bounding box (detection cells at 0.1 m) → display-pixel index window, clamped. + int dxMin = ClampIndex((int)Math.Floor((_minCellE * BITMAP_CELL_SIZE - fMinE) / cell), w); + int dxMax = ClampIndex((int)Math.Floor(((_maxCellE + 1) * BITMAP_CELL_SIZE - fMinE) / cell), w); + int dyMin = ClampIndex((int)Math.Floor((_minCellN * BITMAP_CELL_SIZE - fMinN) / cell), h); + int dyMax = ClampIndex((int)Math.Floor(((_maxCellN + 1) * BITMAP_CELL_SIZE - fMinN) / cell), h); + + for (int dy = dyMin; dy <= dyMax; dy++) + { + long row = (long)dy * w; + for (int dx = dxMin; dx <= dxMax; dx++) + { + ushort px = buf[row + dx]; + if (px == 0) continue; // unpainted display pixel + result.Add((dx, dy, Rgb565ToColor(px), GetDisplayCellAlpha255(dx, dy))); + } + } + } + return result; + } + + private static int ClampIndex(int v, int size) => v < 0 ? 0 : (v >= size ? size - 1 : v); + /// /// Get newly added coverage cells since last call. /// Clears the pending list after returning. @@ -2103,6 +2171,17 @@ private static ushort Rgb888ToRgb565(uint rgb888) return (ushort)(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)); } + // Expand a packed RGB565 display pixel back to 8-bit-per-channel (replicate the high bits + // into the dropped low bits so full-scale stays full-scale). + private static CoverageColor Rgb565ToColor(ushort px) + { + int r5 = (px >> 11) & 0x1F, g6 = (px >> 5) & 0x3F, b5 = px & 0x1F; + return new CoverageColor( + (byte)((r5 << 3) | (r5 >> 2)), + (byte)((g6 << 2) | (g6 >> 4)), + (byte)((b5 << 3) | (b5 >> 2))); + } + /// /// Find closest color index in palette (simple Euclidean distance in RGB565 space). /// diff --git a/Shared/AgOpenWeb.Services/Interfaces/ICoverageMapService.cs b/Shared/AgOpenWeb.Services/Interfaces/ICoverageMapService.cs index f8256bde..2a58d81d 100644 --- a/Shared/AgOpenWeb.Services/Interfaces/ICoverageMapService.cs +++ b/Shared/AgOpenWeb.Services/Interfaces/ICoverageMapService.cs @@ -152,6 +152,15 @@ CoverageResult GetSegmentCoverage( IEnumerable<(int CellX, int CellY, CoverageColor Color)> GetCoverageBitmapCells( double cellSize, double viewMinE, double viewMaxE, double viewMinN, double viewMaxN); + /// + /// The full-coverage snapshot for a server re-init / new-client seed: ONLY the painted + /// display cells (display-cell coords) with their edge-feather alpha (0..255). Walks just the + /// tracked painted bounding box of the RGB565 display buffer, so cost is O(painted area) — it + /// never scans the empty field the way does. Materialized + /// under the coverage lock so it can't observe a mid-expansion (half-resampled) display buffer. + /// + IReadOnlyList<(int X, int Y, CoverageColor Color, int Alpha)> GetPaintedDisplayCells(); + /// /// Get newly added coverage cells since last call (for incremental bitmap updates). /// Clears the pending list after returning. diff --git a/sys/version.h b/sys/version.h index c41e71ff..0b876bdb 100644 --- a/sys/version.h +++ b/sys/version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 26 #define VERSION_MINOR 6 -#define VERSION_PATCH 71 +#define VERSION_PATCH 74 -#define VERSION "26.6.71" -#define VERSION_DATE "2026-07-02" +#define VERSION "26.6.74" +#define VERSION_DATE "2026-07-03"