From 46803f08c34b6ac7230d364174bee10babac1e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:46:00 +0000 Subject: [PATCH] Boundary menu: "+ Polygon" draws an obstacle on the map Beside "+ Circles": tap the corners of a ditch, a pond or a patch on the map (with the imagery under it), Undo, Finish and Cancel on the draw toolbar, Esc cancels. The host (RemoteAddObstacleFromMapPoints, command boundary.obstacle.fromMapPoints) drops a repeated tap on a corner and the closing tap, refuses a corner outside the field or a shape with no area, and adds the obstacle as Hard with the U-turn choice, saved with the field and selected in the list. Until now a non-round obstacle could only be recorded by driving round it or imported from KML. Brought to the fork from feature/obstacle-draw-polygon (official PR #624, not merged yet), cherry-picked from e35212ba onto pulverizador. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKEBDFEFL88R13LC2YPU4t --- Checklists/05-boundary-headland.md | 1 + .../wwwroot/i18n/en.json | 6 ++ .../AgOpenWeb.RemoteServer/wwwroot/index.html | 1 + .../wwwroot/js/frame.js | 1 + .../wwwroot/js/hud-tools.js | 31 ++++++++++ .../wwwroot/js/map-draw.js | 14 +++++ .../wwwroot/js/map-input.js | 1 + .../wwwroot/js/tracks.js | 6 +- .../RemoteServerWiring.cs | 14 +++++ .../MainViewModel.ObstacleDraw.Remote.cs | 60 +++++++++++++++++++ .../ObstacleCircleTests.cs | 35 +++++++++++ 11 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleDraw.Remote.cs diff --git a/Checklists/05-boundary-headland.md b/Checklists/05-boundary-headland.md index c346089b5..b944bf93a 100644 --- a/Checklists/05-boundary-headland.md +++ b/Checklists/05-boundary-headland.md @@ -17,5 +17,6 @@ | 5.10 | Section control at the headland line | | | | | 5.11 | **Boundary imagery**: capture, the on/off toggle, a fallback zoom where tiles are missing | | | | | 5.12 | A boundary from an AgOpenGPS field comes across with its inner boundaries and headland | | | | +| 5.13 | **+ Polygon**: an obstacle drawn by tapping its corners on the map (Undo, Finish, Cancel on the toolbar) is a Hard U-turn obstacle, saved and selected in the list; a corner outside the field is refused | | | | **Signed off:** _ (name, date) diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json index 4cfa0305b..bb229f490 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json @@ -11,6 +11,7 @@ "({n} sats)": "({n} sats)", "(− out / + in)": "(− out / + in)", "+ Circles": "+ Circles", + "+ Polygon": "+ Polygon", "1 cm per click": "1 cm per click", "1 tool width": "1 tool width", "100 = neutral": "100 = neutral", @@ -89,6 +90,7 @@ "All Edges": "All Edges", "Allow steering while reversing": "Allow steering while reversing", "Altitude": "Altitude", + "An obstacle needs at least three corners": "An obstacle needs at least three corners", "An slcan adapter (CANable) on the implement bus, 250 kbit/s.": "An slcan adapter (CANable) on the implement bus, 250 kbit/s.", "Another web card": "Another web card", "Antenna": "Antenna", @@ -298,6 +300,7 @@ "Download Selected": "Download Selected", "Drag the track points, then Save": "Drag the track points, then Save", "Drag the two endpoints (snap to boundary), then Save": "Drag the two endpoints (snap to boundary), then Save", + "Draw an obstacle: tap its corners on the map, then Finish": "Draw an obstacle: tap its corners on the map, then Finish", "Draw boundary on satellite imagery": "Draw boundary on satellite imagery", "Draw on Map": "Draw on Map", "Draw on Map — one line per edge; where they cross encloses the headland": "Draw on Map — one line per edge; where they cross encloses the headland", @@ -737,6 +740,8 @@ "Observer": "Observer", "observing": "observing", "Observing — take control to use hotkeys": "Observing — take control to use hotkeys", + "Obstacle: 1 corner — Finish when done": "Obstacle: 1 corner — Finish when done", + "Obstacle: {n} corners — Finish when done": "Obstacle: {n} corners — Finish when done", "Off": "Off", "Off in headland": "Off in headland", "Off outside boundary": "Off outside boundary", @@ -1054,6 +1059,7 @@ "Tap points ({n}) — Finish when done": "Tap points ({n}) — Finish when done", "Tap points on the map for a curve, then Finish": "Tap points on the map for a curve, then Finish", "Tap the centre of each tree. Tap \"+ Circles\" again to stop.": "Tap the centre of each tree. Tap \"+ Circles\" again to stop.", + "Tap the corners of the obstacle — Finish when done": "Tap the corners of the obstacle — Finish when done", "Tap the end of the edge": "Tap the end of the edge", "Tap the field corners on the imagery — Finish when done": "Tap the field corners on the imagery — Finish when done", "Tap the map": "Tap the map", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html index 7306c485e..65f6e17b3 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html @@ -1529,6 +1529,7 @@ + diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js index ad20787ff..fc0733829 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js @@ -52,6 +52,7 @@ function renderSkia(canvas, rp) { drawRecordingMarkersSk(canvas); // live recorded-path dots (independent of the Scene) drawBoundaryRecordingSk(canvas); // live drive-around boundary line + dots drawSatBoundarySk(canvas); // live boundary-on-satellite polygon being drawn + drawPolyObstacleSk(canvas); // live obstacle polygon being drawn drawAbDrawPreviewSk(canvas); // #40 — A/B (orange/blue) dots for the AB/curve being drawn drawCurveMarksSk(canvas); // record-curve path dots (Set Point A → B) drawBoundaryCurvePreviewSk(canvas); // Bnd. Curve: the chosen way round (#450) diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js index 6131afb01..d75f9672e 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js @@ -454,6 +454,37 @@ document.getElementById('bm-circ-add').addEventListener('pointerdown', ev => { }); renderCircleBar(); }); +// A drawn obstacle ("+ Polygon"): like the boundary drawn on the imagery, the corners are +// tapped on the map and shown as they go; Finish on the draw toolbar sends them, the host adds +// the obstacle (Hard, U-turn) and selects its row. Client-side point buffer for the preview +// only; no geometry in JS. +let polyDraw = false; // drawing an obstacle on the map +let polyPts = []; // [{e,n}] corners tapped so far (preview only) +function startPolyDraw() { + polyDraw = true; polyPts = []; + lnCloseAll(); // close the boundary menu + showAbBar(false, true, true); // Undo + Finish + Cancel + startMapTap({ hint: tr('Tap the corners of the obstacle — Finish when done'), onTap: polyTap }); +} +function polyTap(e, n) { polyPts.push({ e, n }); setPolyHint(); } +function setPolyHint() { hintEl.textContent = polyPts.length === 1 ? tr('Obstacle: 1 corner — Finish when done') : tr('Obstacle: {n} corners — Finish when done', { n: polyPts.length }); } +function polyUndo() { if (polyPts.length) { polyPts.pop(); setPolyHint(); } } +function polyFinish() { + if (polyPts.length >= 3) + transport.send('boundary.obstacle.fromMapPoints|' + polyPts.map(p => p.e.toFixed(3) + ',' + p.n.toFixed(3)).join(';')); + else showToast(tr('An obstacle needs at least three corners')); + endPolyDraw(); +} +function polyCancel() { endPolyDraw(); } +function endPolyDraw() { + polyDraw = false; polyPts = []; + drawBar.classList.remove('show'); endMapTap(); hintEl.classList.remove('show'); +} +document.getElementById('bm-poly-add').addEventListener('pointerdown', ev => { + ev.stopPropagation(); + if (mapTap) endMapTap(); + startPolyDraw(); +}); document.getElementById('bm-circ-move').addEventListener('pointerdown', ev => { ev.stopPropagation(); const it = bmSelItem(); diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js index 517e2360b..d914209fd 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js @@ -341,6 +341,20 @@ function drawSatBoundarySk(canvas) { canvas.drawCircle(xy[0], xy[1], rad, SKP.flagOutline); } } +// Live obstacle draw-on-map preview: the corners tapped so far, in the U-turn obstacle's +// yellow (what a drawn obstacle becomes), closed from three corners on; client-side only. +function drawPolyObstacleSk(canvas) { + if (!polyDraw || !polyPts.length) return; + if (polyPts.length >= 2) strokePtsSk(canvas, polyPts, polyPts.length >= 3, SKP.boundaryInner); + const rad = Math.max(4, 0.5 * pxPerM); + SKP.flagFill.setColor(ckColor('#FFFFFF')); + for (const p of polyPts) { + if ((pw(p.e, p.n)) < 1.0) continue; + const xy = w2s(p.e, p.n); + canvas.drawCircle(xy[0], xy[1], rad, SKP.flagFill); + canvas.drawCircle(xy[0], xy[1], rad, SKP.flagOutline); + } +} // #40: preview the AB/curve points being drawn as dots — orange A (first), // blue B (last), white interior — so the placed A point is visible before B is set. The // host holds the real point list; drawPts is the client's tap echo (preview only). diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-input.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-input.js index d5cd6e580..e48ccd544 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-input.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-input.js @@ -196,6 +196,7 @@ function isTyping() { } addEventListener('keydown', e => { if (e.key === 'Escape' && satBnd) { satCancel(); return; } // cancel boundary draw-on-map + if (e.key === 'Escape' && polyDraw) { polyCancel(); return; } // cancel obstacle draw-on-map if (e.key === 'Escape' && abFlow) { abCancel(); return; } // cancel AB-line creation (tells host) if (e.key === 'Escape' && hlFlow) { endHeadlandDraw(); return; } // cancel headland draw if (e.key === 'Escape' && editSession) { endEdit(); return; } // cancel on-map edit diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/tracks.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/tracks.js index 206c738e5..1b8a2e7d1 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/tracks.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/tracks.js @@ -606,9 +606,9 @@ document.getElementById('fb-hl-edit').addEventListener('pointerdown', e => { e.s // Draw toolbar serves whichever map-draw flow is active (AB line / boundary-on-map / headland). drawBar.addEventListener('pointerdown', e => e.stopPropagation()); // don't pan / not a map tap document.getElementById('draw-setpoint').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); abSetPoint(); }); -document.getElementById('draw-undo').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); hlFlow ? hlUndo() : satBnd ? satUndo() : abUndo(); }); -document.getElementById('draw-finish').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); editSession ? saveEdit() : hlFlow ? endHeadlandDraw() : satBnd ? satFinish() : abFinish(); }); -document.getElementById('draw-cancel').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); editSession ? endEdit() : hlFlow ? endHeadlandDraw() : satBnd ? satCancel() : abCancel(); }); +document.getElementById('draw-undo').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); hlFlow ? hlUndo() : satBnd ? satUndo() : polyDraw ? polyUndo() : abUndo(); }); +document.getElementById('draw-finish').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); editSession ? saveEdit() : hlFlow ? endHeadlandDraw() : satBnd ? satFinish() : polyDraw ? polyFinish() : abFinish(); }); +document.getElementById('draw-cancel').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); editSession ? endEdit() : hlFlow ? endHeadlandDraw() : satBnd ? satCancel() : polyDraw ? polyCancel() : abCancel(); }); // Touch cancel for bare map-tap flows (flag). stopPropagation so the pill isn't taken as a map tap. document.getElementById('maptap-cancel').addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); if (mapTap && mapTap.onCancel) mapTap.onCancel(); else endMapTap(); }); diff --git a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs index ace8b8853..606256a91 100644 --- a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs +++ b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs @@ -331,6 +331,20 @@ void CaptureBoundaryImagery(System.Collections.Generic.IReadOnlyList<(double E, vm.RemoteAddObstacleCircle(bce, bcn, bcd); return; } + case "boundary.obstacle.fromMapPoints": // arg = "e,n;e,n;…": an obstacle drawn + { // on the map, its corners in order + var bop = new System.Collections.Generic.List<(double, double)>(); + foreach (var pair in arg.Split(';')) + { + var en = pair.Split(','); + if (en.Length == 2 + && double.TryParse(en[0], num, inv, out var pe) + && double.TryParse(en[1], num, inv, out var pn)) + bop.Add((pe, pn)); + } + vm.RemoteAddObstacleFromMapPoints(bop); + return; + } case "boundary.circle.diameter": // arg = "row:d": that obstacle becomes a { // circle d metres across var bcc = arg.IndexOf(':'); diff --git a/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleDraw.Remote.cs b/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleDraw.Remote.cs new file mode 100644 index 000000000..cd2a45ada --- /dev/null +++ b/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleDraw.Remote.cs @@ -0,0 +1,60 @@ +// AgOpenWeb +// Copyright (C) 2024-2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using AgOpenWeb.Models; + +namespace AgOpenWeb.ViewModels; + +public partial class MainViewModel +{ + // Two taps closer than this are one corner (a double tap on the touch screen). + private const double DrawnCornerMinSpacing = 0.2; + + /// + /// A new obstacle drawn on the map ("+ Polygon" in the boundary menu): its corners in the + /// order tapped, field metres. Every corner must be inside the field; a repeated tap on a + /// corner is one corner. Its choice is U-turn (what is drawn by hand is a ditch, a pond or a + /// patch, not a tree) and it is Hard: the implement is kept out of it. Its row is selected. + /// + public void RemoteAddObstacleFromMapPoints(IReadOnlyList<(double E, double N)> points) + { + if (!TryLoadBoundaryForEdit(out var boundary, out var fieldPath)) return; + + var corners = new List<(double E, double N)>(); + foreach (var p in points) + if (corners.Count == 0 || Distance(corners[^1], p) >= DrawnCornerMinSpacing) corners.Add(p); + if (corners.Count > 1 && Distance(corners[0], corners[^1]) < DrawnCornerMinSpacing) corners.RemoveAt(corners.Count - 1); + if (corners.Count < 3) + { + ReportFailure("An obstacle needs at least three corners"); + return; + } + if (boundary.OuterBoundary is { IsValid: true } outer) + foreach (var (e, n) in corners) + if (!outer.IsPointInside(e, n)) + { + ReportFailure("Every corner of an obstacle must be inside the field"); + return; + } + + var polygon = new BoundaryPolygon { AtObstacle = ObstacleAction.Turn }; + foreach (var (e, n) in corners) polygon.Points.Add(new BoundaryPoint(e, n, 0)); + polygon.UpdateBounds(); + if (polygon.AreaSquareMeters < 1.0) + { + ReportFailure("The corners drawn enclose no area"); + return; + } + + boundary.AddNewObstacle(polygon); + int inner = boundary.InnerBoundaries.Count - 1; + SaveObstacleChoice(boundary, fieldPath, ListRowOfInner(boundary, inner)); + StatusMessage = $"Inner {inner + 1}: drawn with {corners.Count} corners"; + } + + private static double Distance((double E, double N) a, (double E, double N) b) => + Math.Sqrt((a.E - b.E) * (a.E - b.E) + (a.N - b.N) * (a.N - b.N)); +} diff --git a/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs b/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs index f05ef6759..1867722aa 100644 --- a/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs +++ b/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs @@ -276,4 +276,39 @@ public async Task TheFile_HoldsCentreAndDiameter_AndAPolygonEditedElsewhereLoses Assert.That(inner.Circle, Is.Null, "no longer the circle's polygon"); Assert.That(inner.Points, Has.Count.EqualTo(ObstacleCircle.Segments), "its shape is kept"); } + + [Test] + public async Task AnObstacle_DrawnOnTheMap_IsAHardUTurnObstacle_SavedAndSelected() + { + var vm = await Open(); + + // Five taps: a double tap on the second corner and the first corner tapped again to close. + vm.RemoteAddObstacleFromMapPoints(new[] { (20.0, 160.0), (40.0, 160.0), (40.05, 160.05), (40.0, 180.0), (20.0, 180.0), (20.1, 160.0) }); + + var inner = OnDisk(_dir).InnerBoundaries; + Assert.That(inner, Has.Count.EqualTo(4)); + Assert.That(inner[3].Points, Has.Count.EqualTo(4), "a repeated corner and the closing tap are dropped"); + Assert.That(inner[3].AtObstacle, Is.EqualTo(ObstacleAction.Turn), "what is drawn by hand gets a U-turn"); + Assert.That(inner[3].IsHard, Is.True, "a new obstacle is Hard"); + Assert.That(inner[3].Circle, Is.Null); + Assert.That(inner[3].IsPointInside(30, 170), Is.True); + Assert.That(vm.State.Field.CurrentBoundary!.InnerBoundaries, Has.Count.EqualTo(4), "in the live boundary"); + Assert.That(vm.SelectedBoundaryIndex, Is.EqualTo(4), "its row is selected"); + Assert.That(vm.StatusMessage, Does.Contain("4 corners")); + } + + [Test] + public async Task AnObstacle_DrawnOutsideTheField_OrTooSmall_IsRefused() + { + var vm = await Open(); + + vm.RemoteAddObstacleFromMapPoints(new[] { (190.0, 100.0), (210.0, 100.0), (200.0, 120.0) }); + Assert.That(vm.StatusMessage, Does.Contain("inside the field")); + vm.RemoteAddObstacleFromMapPoints(new[] { (100.0, 100.0), (100.05, 100.05), (100.1, 100.0) }); + Assert.That(vm.StatusMessage, Does.Contain("three corners")); + vm.RemoteAddObstacleFromMapPoints(new[] { (100.0, 100.0), (100.5, 100.0), (101.0, 100.0) }); + Assert.That(vm.StatusMessage, Does.Contain("no area")); + + Assert.That(OnDisk(_dir).InnerBoundaries, Has.Count.EqualTo(3)); + } }