Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Checklists/05-boundary-headland.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,7 @@
<button id="bm-circ-size" class="bm-cbtn" title="Give every circle up to a tree's size the diameter in the box">Size all</button>
<span class="bm-brk"></span>
<button id="bm-circ-add" class="bm-cbtn" title="Add circles: tap the centre of each tree on the map">+ Circles</button>
<button id="bm-poly-add" class="bm-cbtn" title="Draw an obstacle: tap its corners on the map, then Finish">+ Polygon</button>
<button id="bm-circ-move" class="bm-cbtn" title="Tap the map at the selected circle's new centre">Move</button>
<button id="bm-circ-round" class="bm-cbtn" title="Make the selected obstacle round: the smallest circle that holds it">Round</button>
<button id="bm-circ-all" class="bm-cbtn" title="Make every obstacle up to a tree's size round">Round all</button>
Expand Down
1 change: 1 addition & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/js/tracks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(); });

Expand Down
14 changes: 14 additions & 0 deletions Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(':');
Expand Down
60 changes: 60 additions & 0 deletions Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleDraw.Remote.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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));
}
35 changes: 35 additions & 0 deletions Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading