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 Shared/AgOpenWeb.Models/Pipeline/YouTurnSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public record YouTurnSnapshot
// Turn path
public IReadOnlyList<Vec3>? TurnPath { get; init; }
public int PathIndex { get; init; }
public Vec3? ArcStart { get; init; }

// Direction
public bool IsTurnLeft { get; init; }
Expand Down
5 changes: 5 additions & 0 deletions Shared/AgOpenWeb.Models/Pipeline/YouTurnWorkingState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public class YouTurnWorkingState
// Turn path
public List<Vec3>? TurnPath { get; set; }
public int PathIndex { get; set; }
// Where TurnPath leaves the current pass (the arc's first point); null when the path has
// no entry leg. The approach alarm and the too-close rule measure against it.
public Vec3? ArcStart { get; set; }

// Direction
public bool IsTurnLeft { get; set; }
Expand Down Expand Up @@ -106,6 +109,7 @@ public void Reset()
IsTriggered = false;
IsExecuting = false;
TurnPath = null;
ArcStart = null;
PathIndex = 0;
PreviousDistToTurnEnd = double.MaxValue;
DistanceToHeadland = double.MaxValue;
Expand All @@ -128,6 +132,7 @@ public void CompleteTurn()
IsExecuting = false;
IsTriggered = false;
TurnPath = null;
ArcStart = null;
LastTurnWasLeft = IsTurnLeft;
HasCompletedFirstTurn = true;
YouTurnCounter = 0;
Expand Down
10 changes: 10 additions & 0 deletions Shared/AgOpenWeb.Models/State/YouTurnState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ public int PathIndex
set => SetProperty(ref _pathIndex, value);
}

// Where TurnPath leaves the current pass (the arc's first point); null without an entry leg.
private Vec3? _arcStart;
public Vec3? ArcStart
{
get => _arcStart;
set => SetProperty(ref _arcStart, value);
}

// Direction
private bool _isTurnLeft;
public bool IsTurnLeft
Expand Down Expand Up @@ -225,6 +233,7 @@ public void Reset()
IsTriggered = false;
IsExecuting = false;
TurnPath = null;
ArcStart = null;
PathIndex = 0;
DistanceToHeadland = double.MaxValue;
DistanceToTrigger = 0;
Expand All @@ -246,6 +255,7 @@ public void CompleteTurn()
IsExecuting = false;
IsTriggered = false;
TurnPath = null;
ArcStart = null;
LastTurnWasLeft = IsTurnLeft;
HasCompletedFirstTurn = true;
YouTurnCounter = 0;
Expand Down
6 changes: 6 additions & 0 deletions Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationOutput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
/// <summary>
/// Reason for failure (if Success = false).
/// </summary>
public string FailureReason { get; set; }

Check warning on line 35 in Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationOutput.cs

View workflow job for this annotation

GitHub Actions / build-ios

Non-nullable property 'FailureReason' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 35 in Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationOutput.cs

View workflow job for this annotation

GitHub Actions / build-android

Non-nullable property 'FailureReason' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 35 in Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationOutput.cs

View workflow job for this annotation

GitHub Actions / build-desktop

Non-nullable property 'FailureReason' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// Generated U-turn path points.
Expand Down Expand Up @@ -69,5 +69,11 @@
/// Closest point on turn line where turn ends.
/// </summary>
public Vec3 OutClosestTurnPoint { get; set; }

/// <summary>
/// Where the path leaves the current pass: the first point of the arc, before the
/// entry leg was prepended. Null when the path has no entry leg.
/// </summary>
public Vec3? ArcStart { get; set; }
}
}
11 changes: 9 additions & 2 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1995,8 +1995,15 @@ document.getElementById('ln-fieldtools').addEventListener('pointerdown', e => {
const anyOpen = ['fieldtools', 'offsetfix'].some(id => document.getElementById(id).classList.contains('open'));
if (anyOpen) lnCloseAll(); else lnOpen('fieldtools', 'ln-fieldtools');
});
// Field Tools work on the open field; without one, say so instead of asking (#305).
function requireField() {
if (scene && scene.hasField) return true;
showToast(tr('Open a field first'));
return false;
}
document.getElementById('ft-deleteapplied').addEventListener('pointerdown', e => {
e.stopPropagation();
if (!requireField()) return;
showConfirm(tr('Delete Applied Area'),
tr('Delete all applied area coverage? This cannot be undone.'),
() => transport.send('field.deleteApplied'));
Expand Down Expand Up @@ -2032,7 +2039,7 @@ document.getElementById('rp-resume').addEventListener('pointerdown', e => { e.st
document.getElementById('rp-reverse').addEventListener('pointerdown', e => { e.stopPropagation(); transport.send('recpath.reverse'); });
// Boundary recording menu.
document.getElementById('ft-boundary').addEventListener('pointerdown', e => {
e.stopPropagation(); transport.send('boundary.refresh'); lnOpen('boundarymenu', 'ln-fieldtools', renderBoundaryMenu);
e.stopPropagation(); if (!requireField()) return; transport.send('boundary.refresh'); lnOpen('boundarymenu', 'ln-fieldtools', renderBoundaryMenu);
});
document.getElementById('bm-back').addEventListener('pointerdown', e => { e.stopPropagation(); lnOpen('fieldtools', 'ln-fieldtools'); });

Expand All @@ -2042,7 +2049,7 @@ document.getElementById('bm-back').addEventListener('pointerdown', e => { e.stop
let fbTab = 'tracks'; // active FB tab
let fbSel = -1; // selected track index in the Tracks tab
document.getElementById('ft-fieldbuilder').addEventListener('pointerdown', e => {
e.stopPropagation(); fbTab = 'tracks'; fbSel = -1; lnOpen('fieldbuilder', 'ln-fieldtools', renderFieldBuilder);
e.stopPropagation(); if (!requireField()) return; fbTab = 'tracks'; fbSel = -1; lnOpen('fieldbuilder', 'ln-fieldtools', renderFieldBuilder);
});
document.getElementById('fieldbuilder').addEventListener('pointerdown', e => e.stopPropagation()); // panel taps don't pan the map (NO_SCRIM)
document.querySelector('#fieldbuilder .fb-back').addEventListener('pointerdown', e => { e.stopPropagation(); lnOpen('fieldtools', 'ln-fieldtools'); });
Expand Down
1 change: 1 addition & 0 deletions Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@
"On-map track / headland / tram editor": "On-map track / headland / tram editor",
"On-Screen Buttons": "On-Screen Buttons",
"On-screen Kbd": "On-screen Kbd",
"Open a field first": "Open a field first",
"Open Field Only": "Open Field Only",
"Open simulator": "Open simulator",
"Operator": "Operator",
Expand Down
1 change: 1 addition & 0 deletions Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,7 @@ private void ProcessCycle(GpsData data)
IsExecuting = src.IsExecuting,
TurnPath = src.TurnPath,
PathIndex = src.PathIndex,
ArcStart = src.ArcStart,
IsTurnLeft = src.IsTurnLeft,
LastTurnWasLeft = src.LastTurnWasLeft,
DistanceToHeadland = src.DistanceToHeadland,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ public readonly record struct TurnPathResult(List<Vec3>? Path, bool UsedFallback
/// auto-engaged — the operator should be warned to take over.
/// </summary>
public bool ClearanceBlocked { get; init; }

/// <summary>Where the path leaves the current pass (see <see cref="YouTurnCreationOutput.ArcStart"/>);
/// null for a fallback path, which has no entry leg.</summary>
public Vec3? ArcStart { get; init; }
}

/// <summary>
Expand Down Expand Up @@ -225,7 +229,7 @@ public TurnPathResult CreateTurnPath(

_logger.LogDebug("[YouTurn] Path created with {Count} points, net={Net:F0}° cumulative={Cum:F0}° (setback {S:F2}m)",
path.Count, netHeadingChange * 180 / Math.PI, totalHeadingChange * 180 / Math.PI, extraSetback);
return new TurnPathResult(path, UsedFallback: false);
return new TurnPathResult(path, UsedFallback: false) { ArcStart = output.ArcStart };
}

// Creation failed outright.
Expand Down
4 changes: 4 additions & 0 deletions Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public YouTurnCreationService(
private int semiCircleIndex = -1;
private bool isOutOfBounds = false;
private bool isOutSameCurve = false;
private Vec3? arcStart;
private int youTurnPhase = 0;

/// <summary>
Expand Down Expand Up @@ -98,6 +99,7 @@ public YouTurnCreationOutput CreateTurn(YouTurnCreationInput input)
youTurnPhase = 0;
isOutOfBounds = false;
isOutSameCurve = false;
arcStart = null;

// Lateral span of the arc = the pass spacing. NO ToolOffset compensation:
// AgOpenGPS shifts the arc by ±2*ToolOffset because its steering applies the
Expand Down Expand Up @@ -136,6 +138,7 @@ public YouTurnCreationOutput CreateTurn(YouTurnCreationInput input)
output.DistancePivotToTurnLine = Distance(ytList[0], input.PivotPosition);
output.InClosestTurnPoint = inClosestTurnPt.ClosePt;
output.OutClosestTurnPoint = outClosestTurnPt.ClosePt;
output.ArcStart = arcStart;
}
else
{
Expand Down Expand Up @@ -1288,6 +1291,7 @@ private bool AddCurveSequenceLines(YouTurnCreationInput input)
bool sameWay = input.IsHeadingSameWay;

// Entry leg: walk the current pass inward from the arc start for legLength METRES.
arcStart = ytList[0];
WalkLegByDistance(input.GuidancePoints, ytList[0], inClosestTurnPt.CurveIndex,
sameWay ? -1 : 1, legLength, insertFront: true);

Expand Down
135 changes: 115 additions & 20 deletions Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ public sealed class YouTurnStateMachine
// ~20 degrees. Wider tolerances risk creating turns while mid-turn when heading swings.
private static readonly double AlignmentTolerance = Math.PI / 9;

// Trigger when the tractor is within this far of the pre-computed turn start point.
private const double TriggerProximityMeters = 2.0;
// A tractor this close to the planned path, heading along it, is on the path and the
// turn starts (#306). The entry leg is the current pass itself, so the tractor joins it
// wherever it is when the turn is armed; an exact rendezvous with the path's first point
// was never needed.
private const double OnPathLateralMeters = 1.5;

// Completion thresholds.
// ClosestApproachThreshold: if the tractor was this close to the end
Expand Down Expand Up @@ -217,14 +220,23 @@ public YouTurnEffects Tick(in TickContext ctx, GuidanceWorkingState guidance, Yo
HandleNormalCreation(in ctx, track, abHeading, currentPosition, headingRadians, guidance, turn, effects);
}
// ── TURN TRIGGER ────────────────────────────────────────────────
else if (turn.TurnPath != null && turn.TurnPath.Count > 2 && !turn.IsTriggered && !turn.IsExecuting)
// The planned path begins with an entry leg that IS the current pass, so the tractor
// is on the path as soon as it is on that leg and takes the turn from wherever it
// joins (#306). The old rule, within 2 m of the path's first point, never fired when
// the turn was armed late and the leg already began behind the tractor; the path then
// stayed on screen while the tractor drove into the headland with no warning.
if (turn.TurnPath != null && turn.TurnPath.Count > 2 && !turn.IsTriggered && !turn.IsExecuting)
{
var turnStart = turn.TurnPath[0];
double distToTurnStart = Math.Sqrt(
(currentPosition.Easting - turnStart.Easting) * (currentPosition.Easting - turnStart.Easting) +
(currentPosition.Northing - turnStart.Northing) * (currentPosition.Northing - turnStart.Northing));
var arcStart = turn.ArcStart ?? turnStart;
double distToArcStart = Math.Sqrt(
(currentPosition.Easting - arcStart.Easting) * (currentPosition.Easting - arcStart.Easting) +
(currentPosition.Northing - arcStart.Northing) * (currentPosition.Northing - arcStart.Northing));

// Publish the pivot→trigger distance for the UI countdown widget as ARC LENGTH
// Publish the pivot→path-start distance for the UI countdown widget as ARC LENGTH
// ALONG THE TRACK, not straight-line. On a curve that wraps a field end, the turn
// start can be far to the east while the tractor drives west around the wrap toward
// it — a Euclidean distance would COUNT UP as it drives away in a straight line, when
Expand All @@ -234,32 +246,63 @@ public YouTurnEffects Tick(in TickContext ctx, GuidanceWorkingState guidance, Yo
? ArcLengthAlongTrack(track.Points, currentPosition, turnStart)
: distToTurnStart;

// Alarm once as the tractor comes within 20 m of the turn. AgOpenGPS tests a
// 18–20 m band, which a fast approach steps straight over between fixes — the
// alarm then sounded on roughly every other turn (#150).
if (distToTurnStart <= 20.0 && !_approachAlarmed)
// Alarm once as the tractor comes within 20 m of the arc, where the path leaves
// the pass. AgOpenGPS tests a 18–20 m band, which a fast approach steps straight
// over between fixes — the alarm then sounded on roughly every other turn (#150).
if (distToArcStart <= 20.0 && !_approachAlarmed)
{
_approachAlarmed = true;
effects.ApproachAlarmSound = true;
}

// Trigger on physical proximity (straight-line): the tractor must actually reach the
// turn start, regardless of how the arc-length display reads.
if (distToTurnStart <= TriggerProximityMeters)
if (IsOnPath(turn.TurnPath, currentPosition, headingRadians))
{
turn.IsTriggered = true;
turn.IsExecuting = true;
effects.StatusMessage = "YouTurn triggered!";
_logger.LogDebug("[YouTurn] Triggered at {Dist:F2}m from turn start", distToTurnStart);
_logger.LogDebug("[YouTurn] Triggered on the path, {Dist:F1}m from the arc", distToArcStart);
}
else if (isAlignedWithABLine && IsHeadingAlong(turn.TurnPath, headingRadians)
&& IsBehind(arcStart, currentPosition, headingRadians))
{
// The arc already begins behind the tractor, so this path cannot be joined.
// Turn from here instead, the way a manual turn does; when there is no room
// for that either, say so, because the operator has to turn by hand now.
var fromHere = _creation.CreateManualArcPath(
currentPosition, abHeading, turn.IsTurnLeft,
ctx.Boundary, guidance, ctx.UTurnSkipRows, turn.NextTrack);
if (fromHere.Count > 2)
{
turn.TurnPath = fromHere;
turn.ArcStart = null;
turn.YouTurnCounter = 0;
turn.IsTriggered = true;
turn.IsExecuting = true;
effects.StatusMessage = "YouTurn started from here: the planned arc was already behind the tractor";
_logger.LogDebug("[YouTurn] Arc start {Dist:F1}m behind the tractor - turning from here ({Count} points)",
distToArcStart, fromHere.Count);
}
else
{
_logger.LogDebug("[YouTurn] Arc start {Dist:F1}m behind the tractor and no room to turn from here - turn missed",
distToArcStart);
DiscardPlannedTurn(turn);
effects.TurnCreationFailedSound = true;
effects.StatusMessage = "U-turn missed: too close to the headland to turn. Take over manually.";
}
effects.SyncTurnPathToMap = true;
effects.SyncNextTrackToMap = true;
}
else if (isInHeadlandZone)
{
// Drove into the headland without ever joining the path: the turn is missed.
_logger.LogDebug("[YouTurn] Entered the headland without joining the path - turn missed");
DiscardPlannedTurn(turn);
effects.TurnCreationFailedSound = true;
effects.StatusMessage = "U-turn missed: entered the headland off the turn path. Take over manually.";
effects.SyncTurnPathToMap = true;
effects.SyncNextTrackToMap = true;
}
}
// ── TURN RESET (drove past turn start into headland without triggering) ─
else if (turn.TurnPath != null && !turn.IsTriggered && isInHeadlandZone)
{
_logger.LogDebug("[YouTurn] Entered headland without triggering - resetting turn");
DiscardPlannedTurn(turn);
effects.SyncTurnPathToMap = true;
effects.SyncNextTrackToMap = true;
}

// ── TURN COMPLETION ─────────────────────────────────────────────
Expand Down Expand Up @@ -625,6 +668,57 @@ public static void DiscardPlannedTurn(YouTurnWorkingState turn)

// ── Private helpers ─────────────────────────────────────────────────

/// <summary>
/// True when the tractor is on the path: within <see cref="OnPathLateralMeters"/> of its
/// nearest segment and heading along that segment (within <see cref="AlignmentTolerance"/>).
/// The heading test keeps the exit leg, which runs the other way on the next pass, and an
/// omega's crossing legs from counting. No allocation; the path is a few hundred points.
/// </summary>
internal static bool IsOnPath(List<Vec3> path, Position position, double headingRadians)
{
double px = position.Easting, py = position.Northing;
double bestD2 = double.MaxValue;
int best = -1;
for (int i = 0; i < path.Count - 1; i++)
{
double ax = path[i].Easting, ay = path[i].Northing;
double dx = path[i + 1].Easting - ax, dy = path[i + 1].Northing - ay;
double len2 = dx * dx + dy * dy;
if (len2 < 1e-9) continue;
double t = ((px - ax) * dx + (py - ay) * dy) / len2;
if (t < 0) t = 0; else if (t > 1) t = 1;
double ex = ax + t * dx - px, ey = ay + t * dy - py;
double d2 = ex * ex + ey * ey;
if (d2 < bestD2) { bestD2 = d2; best = i; }
}
if (best < 0 || bestD2 > OnPathLateralMeters * OnPathLateralMeters) return false;

double segHeading = Math.Atan2(path[best + 1].Easting - path[best].Easting,
path[best + 1].Northing - path[best].Northing);
double diff = headingRadians - segHeading;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
return Math.Abs(diff) < AlignmentTolerance;
}

/// <summary>True when the tractor drives the way the path sets off (its first segment), within 90°.
/// A tractor going back down the pass is not a candidate for a turn planned at the far end.</summary>
internal static bool IsHeadingAlong(List<Vec3> path, double headingRadians)
{
double segHeading = Math.Atan2(path[1].Easting - path[0].Easting, path[1].Northing - path[0].Northing);
double diff = headingRadians - segHeading;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
return Math.Abs(diff) < Math.PI / 2;
}

/// <summary>True when <paramref name="point"/> lies behind the tractor along its heading.</summary>
internal static bool IsBehind(Vec3 point, Position position, double headingRadians)
{
double dx = point.Easting - position.Easting, dy = point.Northing - position.Northing;
return dx * Math.Sin(headingRadians) + dy * Math.Cos(headingRadians) < 0;
}

private void HandleSnakeCreation(
in TickContext ctx,
Models.Track.Track track,
Expand Down Expand Up @@ -842,6 +936,7 @@ private void CreatePathAndSync(
_approachAlarmed = false;

turn.TurnPath = result.Path;
turn.ArcStart = result.ArcStart;
turn.YouTurnCounter = 0;
if (!result.UsedFallback && effects.StatusMessage == null)
effects.StatusMessage = $"YouTurn path created ({result.Path.Count} points)";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public void ApplyGpsCycleResult(GpsCycleResult result)
// only on turn start/end) — see TMP-001 resolution.
sy.TurnPath = yt.TurnPath is List<AgOpenWeb.Models.Base.Vec3> tp ? tp : yt.TurnPath?.ToList();
sy.PathIndex = yt.PathIndex;
sy.ArcStart = yt.ArcStart;
sy.IsTurnLeft = yt.IsTurnLeft;
sy.LastTurnWasLeft = yt.LastTurnWasLeft;
sy.DistanceToHeadland = yt.DistanceToHeadland;
Expand Down
Loading
Loading