diff --git a/README.md b/README.md index 12041ead..bbabe9f2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The readout was proposed to the official project in [AgOpenGPS-Official/AgOpenWe Most of these have been offered to the official project too. When one is merged there, it comes off this list. -- **Obstacles.** In the boundary list, each obstacle gets its own choice: a U-turn before it, Go around (the pass bends round it), or Drive through. Trees can be added as circles with "+ Circles", and small obstacles made round ("Round all" rounds every obstacle up to 10 m across; a bigger one keeps its shape), and "Size all" gives every circle up to 10 m across the diameter in the box. The Go around curve radius and safety margin are on the U-turn tab. Go around is still being tested: don't use it with AutoSteer on a real tractor yet. +- **Obstacles.** In the boundary list, each obstacle gets its own choice: a U-turn before it, Go around (the pass bends round it), or Drive through. Trees can be added as circles with "+ Circles", and small obstacles made round ("Round all" rounds every obstacle up to 10 m across; a bigger one keeps its shape), and "Size all" gives every circle up to 10 m across the diameter in the box. Obstacles ticked in the list (the box in front of each, or the one in the title for all) are outlined red on the map and deleted together with one confirmation; the outer boundary is never deleted that way. The Go around curve radius and safety margin are on the U-turn tab. Go around is still being tested: don't use it with AutoSteer on a real tractor yet. - **Headland gaps.** With "Drive across a headland gap up to" set on the U-turn tab, a pass that crosses a narrow tip of the field is driven on with the sections off, and the turn is made at the far end. - **U-turns with no room.** When no proper turn fits, the simple turn is moved back into the field instead of crossing the boundary. Where the headland clips a corner off the pass, AutoSteer drives on across it and says so (a thin spike of the headland line doesn't count). - **U-turn beside a fence that comes in.** With a hard boundary, a turn that had to be moved back to keep the implement off the fence at the end of the pass could be made far too early instead, where the fence comes in beside the pass: it landed on the next pass inside its crop and left a strip of it unsprayed. Now it is moved back from the end of the pass only. diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/css/tool-panels.css b/Shared/AgOpenWeb.RemoteServer/wwwroot/css/tool-panels.css index 8b87a81e..78d397d8 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/css/tool-panels.css +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/css/tool-panels.css @@ -53,7 +53,7 @@ .rp-del { background: none; border: 0; color: #c97c7c; font-size: 15px; cursor: pointer; padding: 2px 6px; border-radius: 4px; } .rp-del:hover { background: rgba(220,60,60,0.25); color: var(--text); } /* Boundary recording menu. */ -.bm-panel { width: 500px; font-family: system-ui, sans-serif; } +.bm-panel { width: 542px; font-family: system-ui, sans-serif; } .bm-toolbar { display: flex; gap: 6px; justify-content: space-between; margin-bottom: 10px; } .bm-tbtn { position: relative; width: 56px; height: 48px; border: 1px solid var(--border); border-radius: 8px; background: rgba(var(--ov),0.06); cursor: pointer; display: flex; align-items: center; justify-content: center; } @@ -61,17 +61,17 @@ .bm-tbtn img { width: 32px; height: 32px; } .bm-tbtn:disabled { opacity: 0.35; cursor: default; } .bm-plus { position: absolute; right: 4px; bottom: 2px; color: #F5F54D; font-weight: 700; font-size: 15px; } -.bm-lhdr { display: grid; grid-template-columns: 1fr 0.9fr 0.85fr 0.85fr 0.85fr 0.75fr; align-items: center; background: #1565C0; color: #fff; +.bm-lhdr { display: grid; grid-template-columns: 40px 1fr 0.9fr 0.85fr 0.85fr 0.85fr 0.75fr; align-items: center; background: #1565C0; color: #fff; font-weight: 700; font-size: 12px; padding: 4px 8px; border-radius: 6px 6px 0 0; gap: 2px; } .bm-lhdr > span { text-align: center; } -.bm-lhdr > span:first-child { text-align: left; } +.bm-lhdr > span:first-of-type { text-align: left; } /* A choice's title: tap to give every obstacle that choice. */ .bm-colhdr { min-height: 40px; border: 1px solid rgba(255,255,255,0.35); border-radius: 6px; background: rgba(255,255,255,0.1); color: #fff; font: inherit; font-size: 11px; line-height: 1.15; cursor: pointer; padding: 2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; } .bm-colhdr:hover { background: rgba(255,255,255,0.2); } .bm-list { background: rgba(var(--ov),0.03); border-radius: 0 0 6px 6px; max-height: min(46vh, 400px); overflow-y: auto; } -.bm-row { display: grid; grid-template-columns: 1fr 0.9fr 0.85fr 0.85fr 0.85fr 0.75fr; align-items: center; padding: 4px 8px; +.bm-row { display: grid; grid-template-columns: 40px 1fr 0.9fr 0.85fr 0.85fr 0.85fr 0.75fr; align-items: center; padding: 4px 8px; font-size: 13px; cursor: pointer; border-bottom: 1px solid var(--bg-raised); gap: 2px; min-height: 40px; } .bm-row.sel { background: var(--sel); } .bm-row > span { text-align: center; } @@ -93,6 +93,21 @@ .bm-choice.on .bm-dot.k2 { background: rgb(77,208,245); } .bm-choice.on .bm-dot.k3 { background: rgb(176,176,176); } .bm-hint { font-size: 11px; color: var(--text-muted); margin-top: 6px; } +/* Ticking obstacles to delete several at once: a box in front of each obstacle and in the + title; the ticked ones outlined red on the map (map-draw.js SKP.boundaryTicked). */ +.bm-tick { width: 40px; height: 36px; border: 0; background: transparent; cursor: pointer; padding: 0; + display: flex; align-items: center; justify-content: center; } +.bm-box { width: 22px; height: 22px; box-sizing: border-box; border: 2px solid var(--text-muted); border-radius: 4px; + display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 700; line-height: 1; color: #fff; } +.bm-lhdr .bm-box { border-color: rgba(255,255,255,0.8); } +.bm-tick.on .bm-box { background: rgb(229,57,53); border-color: rgb(229,57,53); } +.bm-tick.on .bm-box::after { content: '\2713'; } +.bm-tick.some .bm-box { background: rgba(229,57,53,0.55); border-color: rgb(229,57,53); } +.bm-tick.some .bm-box::after { content: '\2212'; } +.bm-tickbar { display: flex; align-items: center; gap: 8px; margin-top: 8px; } +.bm-tickcount { flex: 1; font-size: 13px; font-weight: 700; color: rgb(239,83,80); } +.bm-cbtn.bm-danger { border-color: rgb(229,57,53); color: rgb(239,83,80); } +.bm-cbtn.bm-danger:hover { background: rgba(229,57,53,0.2); } /* Round obstacles: the diameter box and the circle buttons under the list. */ .bm-circ { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .bm-circlbl { font-size: 13px; color: var(--text-muted); min-width: 84px; } diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json index 4cfa0305..7da4c8c1 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json @@ -12,6 +12,7 @@ "(− out / + in)": "(− out / + in)", "+ Circles": "+ Circles", "1 cm per click": "1 cm per click", + "1 obstacle ticked": "1 obstacle ticked", "1 tool width": "1 tool width", "100 = neutral": "100 = neutral", "10× time: the simulation runs ten times faster, at the speed shown": "10× time: the simulation runs ten times faster, at the speed shown", @@ -265,12 +266,17 @@ "Delete last point": "Delete last point", "Delete NTRIP Profile": "Delete NTRIP Profile", "Delete NTRIP profile '{name}'?": "Delete NTRIP profile '{name}'?", + "Delete obstacles": "Delete obstacles", "Delete Profile": "Delete Profile", "Delete Recorded Path": "Delete Recorded Path", "Delete selected boundary": "Delete selected boundary", "Delete Selected Profile": "Delete Selected Profile", "Delete selected track": "Delete selected track", + "Delete the ticked obstacle? The outer boundary stays. This cannot be undone.": "Delete the ticked obstacle? The outer boundary stays. This cannot be undone.", + "Delete the ticked obstacles": "Delete the ticked obstacles", + "Delete the {n} ticked obstacles? The outer boundary stays. This cannot be undone.": "Delete the {n} ticked obstacles? The outer boundary stays. This cannot be undone.", "Delete the {type} boundary? This cannot be undone.": "Delete the {type} boundary? This cannot be undone.", + "Delete ticked": "Delete ticked", "Delete Track": "Delete Track", "Delete Tram System": "Delete Tram System", "Delete {kind} profile '{name}'?": "Delete {kind} profile '{name}'?", @@ -1093,6 +1099,8 @@ "This mount point does not send RTCM 3. Its data is forwarded as it comes.": "This mount point does not send RTCM 3. Its data is forwarded as it comes.", "This screen is out of date. Reconnecting…": "This screen is out of date. Reconnecting…", "This screen now has control": "This screen now has control", + "Tick every obstacle, or none": "Tick every obstacle, or none", + "Tick to delete with the others ticked": "Tick to delete with the others ticked", "Tilt toward horizon (3D)": "Tilt toward horizon (3D)", "Tilt toward overhead (2D)": "Tilt toward overhead (2D)", "Time": "Time", @@ -1148,6 +1156,8 @@ "Units": "Units", "Unknown": "Unknown", "Unsaved Coverage": "Unsaved Coverage", + "Untick all": "Untick all", + "Untick every obstacle": "Untick every obstacle", "Up one step": "Up one step", "Upload Selected": "Upload Selected", "Use": "Use", @@ -1237,6 +1247,7 @@ "{n} messages, {bad} bad checksums, {r} replaced by newer": "{n} messages, {bad} bad checksums, {r} replaced by newer", "{n} mount points": "{n} mount points", "{n} mount points, showing {m}. Search to narrow it down.": "{n} mount points, showing {m}. Search to narrow it down.", + "{n} obstacles ticked": "{n} obstacles ticked", "{n} pass": "{n} pass", "{n} s ago": "{n} s ago", "{n} tool widths": "{n} tool widths", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html index 7306c485..f7c27112 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html @@ -1513,8 +1513,10 @@ -
BoundarySize + gives it to every obstacle. Coloured as the obstacles on the map. The box in front of + each obstacle ticks it for deleting several at once (the outer boundary has none); + the one in the title ticks them all, or none. --> +
BoundarySize @@ -1533,7 +1535,14 @@
-
Tap a column title to give every obstacle that choice, or an obstacle on the map to find its row.
+
Tap a column title to give every obstacle that choice, or an obstacle on the map to find its row.
+ +
diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/field-builder.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/field-builder.js index 2cff40df..15673fbf 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/field-builder.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/field-builder.js @@ -285,6 +285,8 @@ for (const b of document.querySelectorAll('#fb-tramedit [data-tmode]')) b.addEve for (const b of document.querySelectorAll('#fb-tramedit [data-tdir]')) b.addEventListener('pointerdown', e => { e.stopPropagation(); tramSet('dir', b.dataset.tdir); }); document.getElementById('bm-delete').addEventListener('pointerdown', e => { e.stopPropagation(); + // Obstacles ticked in the list: those, all together (one confirmation), not the selected row. + if (bmTickedRows().size) { deleteTickedObstacles(); return; } if (!boundary || boundary.selectedIndex < 0) return; const it = boundary.items.find(i => i.index === boundary.selectedIndex); showConfirm(tr('Delete Boundary'), tr('Delete the {type} boundary? This cannot be undone.', { type: it ? it.boundaryType : tr('selected', {}) }), diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js index ad20787f..caf3597f 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/frame.js @@ -17,8 +17,9 @@ function renderSkia(canvas, rp) { drawGridSk(canvas); const outOfBounds = !!(tick && tick.op && tick.op.outOfBounds); // #494 if (scene) { - const selRing = boundaryMenuSelectedRing(); + const selRing = boundaryMenuSelectedRing(), ticked = boundaryMenuTickedRings(); for (let bi = 0; bi < scene.boundaries.length; bi++) { + if (ticked && ticked.has(bi)) strokeFixedPtsSk(canvas, scene.boundaries[bi], true, SKP.boundaryTicked); if (bi === selRing) strokeFixedPtsSk(canvas, scene.boundaries[bi], true, SKP.boundarySel); strokeFixedPtsSk(canvas, scene.boundaries[bi], true, outOfBounds ? SKP.boundaryOut : boundaryRingPaint(bi)); } diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js index 6131afb0..bbd24dc6 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js @@ -341,13 +341,35 @@ function renderRecPath() { // it: a U-turn, go around, or drive through, one lit cell of three. The obstacles are listed by // size, smallest first, so the few big ones sit together at the end. Tap a row to select it // (its obstacle is outlined on the map), a cell to give that obstacle the choice, a column -// title to give it to all of them. +// title to give it to all of them. The box in front of an obstacle ticks it, for deleting +// several at once (a field's trees drawn again). const OBSTACLE_ACTIONS = ['turn', 'goRound', 'driveThrough']; // ObstacleAction 0, 1, 2 let bmShownSel = -1; +// The ticked obstacles, by list row, and the list they were ticked on. The rows are numbered +// by place, so when the list changes (a row added or deleted, an obstacle resized or made +// round, another field) every tick goes: a tick never moves onto another obstacle. A choice or +// the Hard flag changed keeps them. The host checks the row count again before it deletes. +const bmTicks = new Set(); +let bmTickList = ''; +function bmListKey(items) { + return items.map(i => i.index + ':' + i.boundaryType + ':' + i.sizeM + ':' + i.circleD + ':' + i.areaHa).join(';'); +} +function bmTickedRows() { + const key = boundary ? bmListKey(boundary.items) : ''; + if (key !== bmTickList) { bmTicks.clear(); bmTickList = key; } + return bmTicks; +} +function bmToggleTick(index) { + const t = bmTickedRows(); + if (t.has(index)) t.delete(index); else t.add(index); + renderBoundaryMenu(); +} function renderBoundaryMenu() { const b = boundary; const list = document.getElementById('bm-list'); list.innerHTML = ''; const items = b ? b.items.slice() : []; + const ticks = bmTickedRows(); + renderTickBar(items); if (!items.length) { list.innerHTML = '
No boundaries yet — drive around the field to record one.
'; return; } items.sort((x, y) => (x.isInner - y.isInner) || (x.isInner ? x.sizeM - y.sizeM : 0) || x.index - y.index); let selRow = null; @@ -356,10 +378,20 @@ function renderBoundaryMenu() { const sel = b && it.index === b.selectedIndex; row.className = 'bm-row' + (sel ? ' sel' : ''); if (sel) selRow = row; + let tick; + if (it.isInner) { + tick = document.createElement('button'); + tick.className = 'bm-tick' + (ticks.has(it.index) ? ' on' : ''); + tick.title = tr('Tick to delete with the others ticked'); + tick.innerHTML = ''; + tick.addEventListener('pointerdown', ev => { ev.stopPropagation(); bmToggleTick(it.index); }); + } else { + tick = document.createElement('span'); // the outer boundary is never deleted with the obstacles + } const name = document.createElement('span'); name.className = 'bm-name'; name.textContent = it.boundaryType; const size = document.createElement('span'); size.className = 'bm-area'; size.textContent = it.circleD > 0 ? '⌀ ' + fmtUnit(it.circleD, 'm', 1) : it.isInner ? fmtUnit(it.sizeM, 'm', 1) : fmtUnit(it.areaHa, 'ha'); - row.append(name, size); + row.append(tick, name, size); if (it.isInner) { OBSTACLE_ACTIONS.forEach((act, k) => { const c = document.createElement('button'); @@ -393,6 +425,47 @@ function renderBoundaryMenu() { bmShownSel = selIndex; renderCircleBar(); } +// The title's box (all ticked, some, none) and the bar under the list: how many are ticked, +// Untick all and Delete ticked, shown instead of the hint while any is ticked. +function renderTickBar(items) { + const n = bmTicks.size, inner = items.filter(i => i.isInner).length; + const all = document.getElementById('bm-tickall'); + all.classList.toggle('on', n > 0 && n === inner); + all.classList.toggle('some', n > 0 && n < inner); + document.getElementById('bm-tickbar').style.display = n ? 'flex' : 'none'; + document.getElementById('bm-hint').style.display = n ? 'none' : ''; + document.getElementById('bm-tickcount').textContent = n === 1 ? tr('1 obstacle ticked') : tr('{n} obstacles ticked', { n }); +} +// The ticked obstacles, deleted together after one confirmation; the outer boundary stays. +function deleteTickedObstacles() { + const ticks = bmTickedRows(); + if (!ticks.size || !boundary) return; + const rows = [...ticks].sort((x, y) => x - y), count = boundary.items.length; + const msg = rows.length === 1 + ? tr('Delete the ticked obstacle? The outer boundary stays. This cannot be undone.') + : tr('Delete the {n} ticked obstacles? The outer boundary stays. This cannot be undone.', { n: rows.length }); + showConfirm(tr('Delete obstacles'), msg, () => { + transport.send('boundary.deleteObstacles|' + count + ':' + rows.join(',')); + bmTicks.clear(); + renderBoundaryMenu(); + }); +} +document.getElementById('bm-tickall').addEventListener('pointerdown', e => { + e.stopPropagation(); + const ticks = bmTickedRows(); + const inner = boundary ? boundary.items.filter(i => i.isInner) : []; + if (!inner.length) { showToast(tr('The field has no obstacles')); return; } + if (ticks.size === inner.length) ticks.clear(); + else for (const i of inner) ticks.add(i.index); + renderBoundaryMenu(); +}); +document.getElementById('bm-untick').addEventListener('pointerdown', e => { e.stopPropagation(); bmTicks.clear(); renderBoundaryMenu(); }); +document.getElementById('bm-delticked').addEventListener('pointerdown', e => { e.stopPropagation(); deleteTickedObstacles(); }); +// The rings ticked in the open boundary menu (the list and the rings share their index), outlined +// red on the map. Every frame: the ticks were checked against the list when it last drew. +function boundaryMenuTickedRings() { + return bmTicks.size && document.getElementById('boundarymenu').classList.contains('open') ? bmTicks : null; +} function choiceLabel(k) { return k === 1 ? tr('Go around') : k === 2 ? tr('Drive through') : tr('U-turn'); } for (const h of document.querySelectorAll('#boundarymenu .bm-colhdr')) h.addEventListener('pointerdown', e => { e.stopPropagation(); diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js index 517e2360..f9634ec5 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js @@ -615,6 +615,7 @@ function updateLineWidths() { SKP.boundaryInner.setStrokeWidth(wb(3.0)); // boundaryInner 1 × 3 SKP.boundaryGoRound.setStrokeWidth(wb(3.0)); SKP.boundaryDriveThru.setStrokeWidth(wb(3.0)); SKP.boundarySel.setStrokeWidth(wb(3.0) + 5); + SKP.boundaryTicked.setStrokeWidth(wb(3.0) + 7); SKP.boundaryOut.setStrokeWidth(3 * 2 * k); // out of bounds: AgOpenGPS ABLine.lineWidth × 3 (#494) SKP.headland.setStrokeWidth(w(3.0)); // headland 1 × 3 SKP.guidance.setStrokeWidth(w(1.5)); // trackActive 0.5 × 3 diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/skia-setup.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/skia-setup.js index 4891f3fd..6fadbddb 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/skia-setup.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/skia-setup.js @@ -172,9 +172,10 @@ function buildSkPaints() { // Widths are set per frame in updateLineWidths() (world metres × pxPerM). boundary: mk('rgba(242,112,89,0.8)', 5), boundaryInner: mk('rgb(245,245,77)', 5), headland: mk('rgb(251,235,107)', 4), // An obstacle's colour is its choice: a U-turn yellow (boundaryInner), go around blue, drive - // through grey and dashed; the one selected in the boundary menu is outlined white. + // through grey and dashed; the one selected in the boundary menu is outlined white, the + // ones ticked there for deleting red. boundaryGoRound: mk('rgb(77,208,245)', 5), boundaryDriveThru: mk('rgb(176,176,176)', 5, [6, 5]), - boundarySel: mk('rgb(255,255,255)', 9), + boundarySel: mk('rgb(255,255,255)', 9), boundaryTicked: mk('rgb(229,57,53)', 11), // Out of bounds (#494, AgOpenGPS CFence / OpenGL.Designer): every boundary thick and red, // and an orange frame round the map. boundaryOut: mk('rgb(242,64,64)', 6), outFrame: mk('rgb(255,168,84)', 8), diff --git a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs index ace8b885..3294a51a 100644 --- a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs +++ b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs @@ -318,6 +318,19 @@ void CaptureBoundaryImagery(System.Collections.Generic.IReadOnlyList<(double E, vm.RemoteSetObstacleAction(boi, boa); return; } + case "boundary.deleteObstacles": // arg = "rows:i,j,k": the obstacles on list + { // rows i, j, k, the list being that many rows long + var bdc = arg.IndexOf(':'); + if (bdc <= 0 || !int.TryParse(arg[..bdc], out var bdn)) return; + var bdr = new List(); + foreach (var s in arg[(bdc + 1)..].Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (!int.TryParse(s, out var r)) return; // a bad row: nothing deleted + bdr.Add(r); + } + vm.RemoteDeleteObstacles(bdn, bdr); + return; + } case "boundary.atObstacleAll": // arg = turn|goRound|driveThrough: every if (AgOpenWeb.Models.ObstacleActionNames.Parse(arg) is { } baa) // obstacle (a vm.RemoteSetAllObstacleActions(baa); // column title) diff --git a/Shared/AgOpenWeb.ViewModels/MainViewModel.Obstacles.Remote.cs b/Shared/AgOpenWeb.ViewModels/MainViewModel.Obstacles.Remote.cs index 311997c5..d1049c2e 100644 --- a/Shared/AgOpenWeb.ViewModels/MainViewModel.Obstacles.Remote.cs +++ b/Shared/AgOpenWeb.ViewModels/MainViewModel.Obstacles.Remote.cs @@ -10,7 +10,8 @@ namespace AgOpenWeb.ViewModels; /// /// MainViewModel partial: the boundary list's choice per obstacle (what the tractor does at an /// inner boundary, ), one row at a time or every obstacle at once -/// from a column's title. Saved with the field like the drive-through and hard flags. +/// from a column's title. Saved with the field like the drive-through and hard flags. Also the +/// obstacles ticked in the list, deleted together. /// public partial class MainViewModel { @@ -54,6 +55,54 @@ public void RemoteSetAllObstacleActions(ObstacleAction action) StatusMessage = $"All {count} inner boundaries: {Describe(action)}"; } + /// + /// The obstacles on boundary list rows are deleted at once, with + /// one confirmation in the web client instead of one per row: a field's trees drawn again (a + /// tester had over 40 to delete one by one). Only obstacles: the outer boundary is never + /// deleted this way (on its own it can go only when it is the last one, as AgOpenGPS). + /// is how many rows the client's list had when the operator + /// ticked them; a list of another length (a row added or deleted in between) changes nothing, + /// as does a row that is not an obstacle, so a row number never names another obstacle. Fork only. + /// + public void RemoteDeleteObstacles(int listRowCount, IReadOnlyCollection listRows) + { + if (!TryLoadBoundaryForEdit(out var boundary, out var fieldPath)) return; + int rows = (boundary.OuterBoundary is { IsValid: true } ? 1 : 0) + boundary.InnerBoundaries.Count(b => b.IsValid); + if (listRowCount != rows) + { + ReportFailure("The boundary list changed: tick the obstacles again"); + return; + } + + var inner = new SortedSet(); + foreach (int row in listRows) + { + int i = InnerIndexOfListRow(boundary, row); + if (i < 0) + { + ReportFailure("Only obstacles can be deleted together: nothing deleted"); + return; + } + inner.Add(i); + } + if (inner.Count == 0) + { + ReportFailure("Tick the obstacles to delete"); + return; + } + + foreach (int i in inner.Reverse()) boundary.InnerBoundaries.RemoveAt(i); + SaveFieldBoundary(boundary, fieldPath); + RefreshBoundaryList(); + SetCurrentBoundary(boundary); + + // As one delete: with no boundary left, the background photo placed on it goes too. + bool left = boundary.OuterBoundary is { IsValid: true } || boundary.InnerBoundaries.Any(b => b.IsValid); + if (!left) DeleteBackgroundImage(fieldPath); + StatusMessage = (inner.Count == 1 ? "1 obstacle deleted" : $"{inner.Count} obstacles deleted") + + (left ? "" : "; background image removed"); + } + private bool TryLoadBoundaryForEdit(out Boundary boundary, out string fieldPath) { boundary = null!; diff --git a/Tests/AgOpenWeb.ViewModels.Tests/DeleteTickedObstaclesTests.cs b/Tests/AgOpenWeb.ViewModels.Tests/DeleteTickedObstaclesTests.cs new file mode 100644 index 00000000..e5e9b8f3 --- /dev/null +++ b/Tests/AgOpenWeb.ViewModels.Tests/DeleteTickedObstaclesTests.cs @@ -0,0 +1,156 @@ +// AgOpenWeb +// Copyright (C) 2024-2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using AgOpenWeb.Models; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Services; +using AgOpenWeb.Services.GeoJson; +using NSubstitute; + +namespace AgOpenWeb.ViewModels.Tests; + +/// +/// The obstacles ticked in the boundary list are deleted together, after one confirmation in the +/// web client (a tester redrawing a field's trees had over 40 to delete one by one). The outer +/// boundary is never deleted this way, and a list that changed since the ticks were made (another +/// length) or a row that is not an obstacle deletes nothing, so a row number never names another +/// obstacle. +/// +[TestFixture] +[NonParallelizable] // ConfigurationStore is a singleton +public class DeleteTickedObstaclesTests +{ + private string _root = null!; + private string _dir = null!; + private MainViewModelBuilder _b = null!; + + private static BoundaryPolygon Square(double e0, double n0, double side) + { + var p = new BoundaryPolygon(); + foreach (var (e, n) in new[] { (e0, n0), (e0 + side, n0), (e0 + side, n0 + side), (e0, n0 + side) }) + p.Points.Add(new BoundaryPoint(e, n, 0)); + p.UpdateBounds(); + return p; + } + + [SetUp] + public void SetUp() + { + ConfigurationStore.SetInstance(new ConfigurationStore()); + _root = Path.Combine(Path.GetTempPath(), "aow-delticked-" + Guid.NewGuid().ToString("N")); + _dir = Path.Combine(_root, "F"); + Directory.CreateDirectory(_dir); + var b = new Boundary { OuterBoundary = Square(0, 0, 200) }; + b.InnerBoundaries.Add(Square(20, 20, 4)); // Inner 1, list row 1 + b.InnerBoundaries.Add(Square(60, 20, 5)); // Inner 2, row 2 + b.InnerBoundaries.Add(Square(100, 20, 6)); // Inner 3, row 3 + b.InnerBoundaries.Add(Square(140, 20, 30)); // Inner 4, row 4: a big patch that stays + GeoJsonFieldService.Save(new Field + { + Name = "F", DirectoryPath = _dir, Origin = new Position { Latitude = 39.42, Longitude = -7.53 }, Boundary = b, + }, tracks: null); + _b = new MainViewModelBuilder { FieldService = new FieldService() }; + _b.SettingsService.Settings.Returns(new AppSettings { FieldsDirectory = _root }); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_root, true); } catch { } + ConfigurationStore.SetInstance(new ConfigurationStore()); + } + + private static Boundary OnDisk(string dir) => new FieldService().LoadField(dir).Boundary!; + + private async Task Open() + { + var vm = _b.Build(); + await vm.OpenFieldAsync(_dir, "F"); + vm.RefreshBoundaryList(); + return vm; + } + + private static double[] Easting(Boundary b) => b.InnerBoundaries.Select(i => i.Points.Min(p => p.Easting)).ToArray(); + + [Test] + public async Task The_ticked_obstacles_go_together_and_the_rest_stay() + { + var vm = await Open(); + vm.SelectedBoundaryIndex = 2; + + vm.RemoteDeleteObstacles(5, new[] { 3, 1 }); + + var disk = OnDisk(_dir); + Assert.Multiple(() => + { + Assert.That(Easting(disk), Is.EqualTo(new double[] { 60, 140 }).Within(0.01), "Inner 1 and 3 gone, 2 and 4 kept, in order"); + Assert.That(disk.OuterBoundary, Is.Not.Null.And.Property(nameof(BoundaryPolygon.IsValid)).True, "the outer boundary stays"); + Assert.That(vm.State.Field.CurrentBoundary!.InnerBoundaries, Has.Count.EqualTo(2), "in the live boundary"); + Assert.That(vm.BoundaryItems.Select(i => i.BoundaryType), Is.EqualTo(new[] { "Outer", "Inner 1", "Inner 2" })); + Assert.That(vm.SelectedBoundaryIndex, Is.EqualTo(-1), "the rows moved: nothing stays selected"); + Assert.That(vm.StatusMessage, Is.EqualTo("2 obstacles deleted")); + }); + } + + [Test] + public async Task Every_obstacle_ticked_leaves_the_outer_boundary_alone() + { + var vm = await Open(); + + vm.RemoteDeleteObstacles(5, new[] { 1, 2, 3, 4 }); + + var disk = OnDisk(_dir); + Assert.That(disk.InnerBoundaries, Is.Empty); + Assert.That(disk.OuterBoundary!.IsValid, Is.True); + Assert.That(vm.BoundaryItems.Select(i => i.BoundaryType), Is.EqualTo(new[] { "Outer" })); + Assert.That(vm.StatusMessage, Is.EqualTo("4 obstacles deleted")); + } + + [Test] + public async Task The_outer_boundary_among_the_rows_deletes_nothing() + { + var vm = await Open(); + + vm.RemoteDeleteObstacles(5, new[] { 0, 1 }); + + Assert.That(OnDisk(_dir).InnerBoundaries, Has.Count.EqualTo(4)); + Assert.That(OnDisk(_dir).OuterBoundary!.IsValid, Is.True); + Assert.That(vm.StatusMessage, Does.Contain("Only obstacles")); + } + + [Test] + public async Task A_list_of_another_length_deletes_nothing() + { + var vm = await Open(); + + vm.RemoteDeleteObstacles(4, new[] { 1 }); // ticked before a row was added or deleted + + Assert.That(OnDisk(_dir).InnerBoundaries, Has.Count.EqualTo(4)); + Assert.That(vm.StatusMessage, Does.Contain("list changed")); + } + + [Test] + public async Task A_row_past_the_end_or_no_row_deletes_nothing() + { + var vm = await Open(); + + vm.RemoteDeleteObstacles(5, new[] { 1, 5 }); + Assert.That(OnDisk(_dir).InnerBoundaries, Has.Count.EqualTo(4)); + + vm.RemoteDeleteObstacles(5, Array.Empty()); + Assert.That(OnDisk(_dir).InnerBoundaries, Has.Count.EqualTo(4)); + Assert.That(vm.StatusMessage, Does.Contain("Tick the obstacles")); + } + + [Test] + public async Task A_row_ticked_twice_is_one_obstacle() + { + var vm = await Open(); + + vm.RemoteDeleteObstacles(5, new[] { 2, 2 }); + + Assert.That(Easting(OnDisk(_dir)), Is.EqualTo(new double[] { 20, 100, 140 }).Within(0.01)); + Assert.That(vm.StatusMessage, Is.EqualTo("1 obstacle deleted")); + } +}