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));
+ }
}