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 4791c426..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) {
@@ -356,9 +353,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);
@@ -3861,14 +3863,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; } }
});
@@ -4854,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
@@ -4865,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);
@@ -4891,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) + ')';
@@ -4905,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/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);
}
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);
diff --git a/sys/version.h b/sys/version.h
index d9b07ac8..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 69
+#define VERSION_PATCH 74
-#define VERSION "26.6.69"
-#define VERSION_DATE "2026-07-02"
+#define VERSION "26.6.74"
+#define VERSION_DATE "2026-07-03"