diff --git a/README.md b/README.md index a267b65f..72cce743 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). 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. 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 550915ce..8b87a81e 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/css/tool-panels.css +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/css/tool-panels.css @@ -96,6 +96,7 @@ /* 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; } +.bm-brk { flex-basis: 100%; height: 0; } /* the tools on a row of their own, under the size */ .bm-circin { width: 64px; height: 40px; box-sizing: border-box; padding: 0 6px; font-size: 15px; text-align: right; border: 1px solid var(--border); border-radius: 6px; background: rgba(var(--ov),0.06); color: inherit; } .bm-cbtn { height: 40px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: transparent; diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json index d16b467b..4cfa0305 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json @@ -167,6 +167,7 @@ "Choose a page": "Choose a page", "Choose a page above.": "Choose a page above.", "Choose this VT in the implement's own settings.": "Choose this VT in the implement's own settings.", + "Circle size": "Circle size", "Clear all points": "Clear all points", "Click a key, then press the new key. A bound key replaces the map key it shares (F follow, 3 tilt, arrows/space simulator).": "Click a key, then press the new key. A bound key replaces the map key it shares (F follow, 3 tilt, arrows/space simulator).", "Click Finish to save the settings and close the wizard": "Click Finish to save the settings and close the wizard", @@ -352,6 +353,7 @@ "Error: Caster host is required": "Error: Caster host is required", "Error: Mount point is required": "Error: Mount point is required", "Every": "Every", + "Every circle at once: up to {max} across. Set a bigger one on its own": "Every circle at once: up to {max} across. Set a bigger one on its own", "Every obstacle": "Every obstacle", "Extend line at A (+5 m)": "Extend line at A (+5 m)", "Extend line at B (+5 m)": "Extend line at B (+5 m)", @@ -423,7 +425,9 @@ "GitHub Repository": "GitHub Repository", "GitHub username": "GitHub username", "Give all {n} obstacles \"{choice}\"?": "Give all {n} obstacles \"{choice}\"?", + "Give every circle up to a tree's size the diameter in the box": "Give every circle up to a tree's size the diameter in the box", "Give every obstacle a U-turn": "Give every obstacle a U-turn", + "Give the {n} circles up to {max} across a diameter of {d}? Bigger circles and drawn obstacles keep their size. This cannot be undone.": "Give the {n} circles up to {max} across a diameter of {d}? Bigger circles and drawn obstacles keep their size. This cannot be undone.", "GLONASS code-phase biases": "GLONASS code-phase biases", "GLONASS ephemeris": "GLONASS ephemeris", "GLONASS observations (legacy)": "GLONASS observations (legacy)", @@ -670,6 +674,7 @@ "No boundaries yet — drive around the field to record one.": "No boundaries yet — drive around the field to record one.", "No boundary": "No boundary", "No boundary to offset": "No boundary to offset", + "No circle up to {max} across": "No circle up to {max} across", "No cloud fields (Refresh to load).": "No cloud fields (Refresh to load).", "No connection to AgOpenWeb": "No connection to AgOpenWeb", "No connection: the report sends by itself when the device is back online.": "No connection: the report sends by itself when the device is back online.", @@ -957,6 +962,7 @@ "Single": "Single", "Single antenna": "Single antenna", "Size": "Size", + "Size all": "Size all", "Skip": "Skip", "Slow": "Slow", "Slow speed cutoff": "Slow speed cutoff", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html index 25a00ee5..7306c485 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html @@ -1521,10 +1521,13 @@ Hard
+ get; new circles are placed by a tap on each tree's centre on the map. "Size all" gives + every circle up to a tree's size the diameter in the box. -->
New circles + + diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js index 8f5bc344..6131afb0 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/hud-tools.js @@ -407,7 +407,8 @@ for (const h of document.querySelectorAll('#boundarymenu .bm-colhdr')) h.addEven // the next tree gets the size of the one just placed. "+ Circles" stays armed: every tap on the // map puts a circle centred there, until it is tapped again or the menu closes. "Round" makes // the selected drawn obstacle the smallest circle that holds it, "Round all" every one up to a -// tree's size. A tap on an obstacle already there selects it instead of adding a circle on it. +// tree's size, "Size all" every circle up to a tree's size the diameter in the box. A tap on an +// obstacle already there selects it instead of adding a circle on it. const CIRCLE_D_KEY = 'aow.newCircleDiameter'; const ROUND_ALL_MAX_D = 10; // metres across: the host's ObstacleCircle.MaxRoundAllDiameter let bmNewD = 6; @@ -482,6 +483,23 @@ document.getElementById('bm-circ-all').addEventListener('pointerdown', ev => { tr('Make the obstacles up to {d} across round? Each becomes the smallest circle that holds it; long, thin ones and bigger ones keep their shape. This cannot be undone.', { d: fmtUnit(ROUND_ALL_MAX_D, 'm', 0) }), () => transport.send('boundary.circle.roundAll')); }); +document.getElementById('bm-circ-size').addEventListener('pointerdown', ev => { + ev.stopPropagation(); + // Trees only: a bigger circle (a pond, a clump made round) keeps its size, and the diameter is + // a tree's too, so the trees can be changed again (the host checks both). No undo. + const d = readUnitInput(bmCircD); + if (!circleDiameterOk(d)) { + showToast(tr('A circle is {min} to {max} across', { min: fmtUnit(0.5, 'm', 1), max: fmtUnit(100, 'm', 0) })); + renderCircleBar(); return; + } + const max = fmtUnit(ROUND_ALL_MAX_D, 'm', 0); + if (d > ROUND_ALL_MAX_D + 0.001) { showToast(tr('Every circle at once: up to {max} across. Set a bigger one on its own', { max })); return; } + const n = boundary ? boundary.items.filter(i => i.isInner && i.circleD > 0 && i.circleD <= ROUND_ALL_MAX_D + 0.001).length : 0; + if (!n) { showToast(tr('No circle up to {max} across', { max })); return; } + showConfirm(tr('Circle size'), + tr('Give the {n} circles up to {max} across a diameter of {d}? Bigger circles and drawn obstacles keep their size. This cannot be undone.', { n, max, d: fmtUnit(d, 'm', 1) }), + () => transport.send('boundary.circle.diameterAll|' + d)); +}); // The scene ring of the smallest obstacle holding the point (world metres), or -1. function obstacleRingAt(e, n) { if (!scene || !scene.boundaries) return -1; diff --git a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs index 505a0600..ace8b885 100644 --- a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs +++ b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.cs @@ -339,6 +339,10 @@ void CaptureBoundaryImagery(System.Collections.Generic.IReadOnlyList<(double E, vm.RemoteSetObstacleCircleDiameter(bcr, bcdd); return; } + case "boundary.circle.diameterAll": // arg = d: every circle up to a + if (double.TryParse(arg, num, inv, out var bcda)) // tree's size + vm.RemoteSetObstacleCirclesDiameter(bcda); // gets it + return; case "boundary.circle.move": // arg = "row:e,n": that obstacle becomes a { // circle centred there var bcc = arg.IndexOf(':'); diff --git a/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleCircles.Remote.cs b/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleCircles.Remote.cs index b18be020..8e641677 100644 --- a/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleCircles.Remote.cs +++ b/Shared/AgOpenWeb.ViewModels/MainViewModel.ObstacleCircles.Remote.cs @@ -120,6 +120,47 @@ public void RemoteMakeObstaclesRound() else StatusMessage = $"{made} obstacles made round"; } + /// + /// Every circle up to a tree's size (, 10 m) gets + /// , round its own centre: the trees of a field put down at one size + /// and found to be another (a tester's field: every tree a 10 m circle, the crowns pruned so the + /// implement passes under all but the trunk's 1-2 m). A bigger circle (a pond, a clump made + /// round) keeps its size, and the diameter is a tree's too, at most 10 m, so the trees stay + /// within reach of the next change. Drawn obstacles are left as drawn. Fork only. + /// + public void RemoteSetObstacleCirclesDiameter(double diameter) + { + if (!CheckDiameter(diameter)) return; + if (diameter > ObstacleCircle.MaxRoundAllDiameter) + { + ReportFailure($"Every circle at once: up to {Metres(ObstacleCircle.MaxRoundAllDiameter)} across. Set a bigger one on its own"); + return; + } + if (!TryLoadBoundaryForEdit(out var boundary, out var fieldPath)) return; + int changed = 0, trees = 0, kept = 0; + foreach (var inner in boundary.InnerBoundaries) + { + if (!inner.IsValid || inner.Circle is not { } circle) continue; + if (!(circle.Diameter <= ObstacleCircle.MaxRoundAllDiameter + 0.001)) + { + kept++; + continue; + } + trees++; + if (Math.Abs(circle.Diameter - diameter) < 0.001) continue; + inner.SetCircle(circle with { Diameter = diameter }); + changed++; + } + if (trees == 0) + { + ReportFailure(kept > 0 ? $"No circle up to {Metres(ObstacleCircle.MaxRoundAllDiameter)} across: {kept} bigger, kept as they are" : "The field has no circles"); + return; + } + if (changed > 0) SaveObstacleChoice(boundary, fieldPath, SelectedBoundaryIndex); + if (kept > 0) ReportFailure($"{trees} circles {Metres(diameter)} across; {kept} over {Metres(ObstacleCircle.MaxRoundAllDiameter)} kept as they are"); + else StatusMessage = $"{trees} circles {Metres(diameter)} across"; + } + private bool CheckDiameter(double diameter) { if (ObstacleCircle.IsValidDiameter(diameter)) return true; diff --git a/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs b/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs index f3ce19b6..f05ef675 100644 --- a/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs +++ b/Tests/AgOpenWeb.ViewModels.Tests/ObstacleCircleTests.cs @@ -195,6 +195,50 @@ public async Task ACircle_GetsANewDiameter_AndANewCentre() Assert.That(OnDisk(_dir).InnerBoundaries[3].IsPointInside(80, 150), Is.False, "the polygon moved with it"); } + [Test] + public async Task SizeAll_GivesEveryTreeCircleTheDiameter_AndLeavesTheRest() + { + // Trees put down as 10 m circles, pruned so the implement passes under all but the trunk's + // 1-2 m (a tester's field), a pond made round (17 m) and the two trees drawn as squares. + var vm = await Open(); + vm.RemoteAddObstacleCircle(80, 150, 10); + vm.RemoteAddObstacleCircle(100, 150, 10); + vm.RemoteAddObstacleCircle(120, 150, 6); + vm.RemoteAddObstacleCircle(30, 120, 17); + + vm.RemoteSetObstacleCirclesDiameter(3); + + var inner = OnDisk(_dir).InnerBoundaries; + Assert.That(inner.Skip(3).Select(i => i.Circle!.Value.Diameter), Is.EqualTo(new[] { 3.0, 3.0, 3.0, 17.0 }).AsCollection, + "the trees, not the 17 m circle"); + Assert.That(inner[3].Circle!.Value.CenterEasting, Is.EqualTo(80).Within(0.005), "round each one's own centre"); + Assert.That(inner[4].IsPointInside(100 + 1.6, 150), Is.False, "the polygon shrank with it"); + Assert.That(inner.Take(3).Select(i => i.Circle.HasValue), Is.All.False, "drawn obstacles are left as drawn"); + Assert.That(inner[1].Points, Has.Count.EqualTo(4)); + Assert.That(vm.StatusMessage, Does.Contain("3 circles 3.0 m across").And.Contain("1 over 10.0 m kept")); + Assert.That(vm.BoundaryItems[4].CircleDiameter, Is.EqualTo(3), "the list shows it"); + + vm.RemoteSetObstacleCirclesDiameter(4); // the trees are still trees + Assert.That(OnDisk(_dir).InnerBoundaries.Skip(3).Select(i => i.Circle!.Value.Diameter), + Is.EqualTo(new[] { 4.0, 4.0, 4.0, 17.0 }).AsCollection); + } + + [Test] + public async Task SizeAll_OverATreesSize_OrWithNoCircles_ChangesNothing() + { + var vm = await Open(); + + vm.RemoteSetObstacleCirclesDiameter(3); + Assert.That(vm.StatusMessage, Does.Contain("no circles")); + + vm.RemoteAddObstacleCircle(80, 150, 6); + vm.RemoteSetObstacleCirclesDiameter(12); + Assert.That(vm.StatusMessage, Does.Contain("up to 10.0 m across")); + vm.RemoteSetObstacleCirclesDiameter(0.2); + Assert.That(vm.StatusMessage, Does.Contain("across")); + Assert.That(OnDisk(_dir).InnerBoundaries[3].Circle!.Value.Diameter, Is.EqualTo(6)); + } + [Test] public async Task TheOuterRow_CannotBeMadeRound() {