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"