diff --git a/CLAUDE.md b/CLAUDE.md index f1a25b0c9..9c934c979 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,12 @@ sections), the `Scene` (tracks, boundary, imagery extent), `Config`, `Status`, c init + incremental cell deltas, and smaller frames for dialogs, prompts, sounds and toasts. Position between ticks is extrapolated client-side (`RENDER_DELAY`); the pose estimator on the host does the same for the 100 Hz control loop. +Long lines cost the most to draw: Skia strokes a bent, anti-aliased path through a CPU mask the +size of the path on the screen, so a curve that goes round the field costs a screen-sized mask. +Lines from the `Scene` are drawn with `strokeFixedPtsSk` (`js/map-draw.js`: a copy without the +points that add nothing, made once per array; the host replaces its arrays, the page never edits +them); a line the page draws or edits uses `strokePtsSk`. Lines in one style share one path +(`strokeCmdsSk`), as the extra guidelines do. ### Control authority Several browsers may be connected; only one holds control (`ControlAuthority`, deadman model). @@ -561,7 +567,7 @@ dotnet run --project TestRunner/TestRunner.csproj - `AgOpenWeb.WorkingDayScenarios.Tests` - an eight-hour working day in one process (fields, refills, lunch, a tablet and a phone that drop, stall and reconnect, the next morning): memory baselines, heap census, cycle and tick times, the deadman with a second browser; about 45 minutes (`working-day-scenarios.yml`) - `AgOpenWeb.DumpReplayScenarios.Tests` - the bug report dumps attached to public issues, replayed on this build: the reporter's settings, field, job and track, their GPS log fed back through the pipeline and compared with what their build did, then a machine of their shape taken over with AutoSteer and U-turns; the bug report service's dumps only in a local run (`AGOPENWEB_REPLAY_PRIVATE=1`), never in CI (`dump-replay-scenarios.yml`) -**Running the harnesses:** on the harness host, never on a development machine (`Tools/harness-runner`: a Docker image run on the `runner` context; `runner-compare.sh` runs a fix and its base side by side and lists the findings new, gone and in both, `Tools/compare-reports.py`). Every harness stops at the start when another process holds UDP 9999 or 8888 (`Tests/ScenarioShared/PortGuard.cs`, linked into each): an app left running shares the packets and the findings are noise. Before rerunning, read the saved report, pictures and traces; one confirming run per fix. +**Running the harnesses:** on the harness host, never on a development machine (`Tools/harness-runner`: a Docker image run on the `runner` context; `runner-compare.sh` runs a fix and its base side by side and lists the findings new, gone and in both for each report, `Tools/compare-reports.py`; it exits 1 when a side did not pass or a report is missing). Every harness stops at the start when another process holds UDP 9999 or 8888 (`Tests/ScenarioShared/PortGuard.cs`, linked into each): an app left running shares the packets and the findings are noise. Before rerunning, read the saved report, pictures and traces; one confirming run per fix. CI also runs `Tools/i18n-extract.py --check` so `en.json` stays current, `Tools/license-headers.py --check` for the licence headers, and `Tools/i18n-limits.py --check` (job `translations-fit`, only when a change touches `wwwroot/`, the check, its baseline or the workflow; otherwise skipped, which counts as passed) so every translated label fits the English layout. @@ -585,7 +591,7 @@ turns from the on-screen buttons) with paths built by `YouTurnCreationService`: - Obstacles chosen as *Go around* (`AtObstacle == GoRound`; a new circle's default) bend the pass round them (`ObstacleDetour`: the obstacle grown by half the implement plus the Safety margin, the offset limited to 45° and rounded to the Curve radius, both settings on the U-turn tab and in the tool profile). One `GoAroundPasses` singleton serves the pipeline (steering, display, light bar) and the U-turn planner (the current pass walked to the turn, the next pass the exit lands on), so a turn is never planned from a line the tractor does not drive; it pins the side of each obstacle while the tractor is beside it and over on that side, so a nudge or snap never rebuilds the bend across the tree (the pin is let go when the tractor gets to the other side, as a U-turn next to the tree does). An obstacle with no room on the allowed side is promoted to a U-turn obstacle for that pass (toast "No room to go around Inner N on this pass: U-turn before it"); a turn keeps the tractor out of every Go around obstacle and the implement off a hard one; and with AutoSteer on, a Go around obstacle within 10 m plus 2 s straight ahead while the tractor is more than 1.5 m off the steered line takes AutoSteer off with the alarm. The margin is never under 0.3 m on a 6 m bend and more on a tighter one (`GoAroundPasses.MinMarginFor`), and the radius never under the tractor's minimum turning radius (`GoAroundPasses.Radius`). A bend never moves the line more than two implement widths (never under 20 m) off the pass (`ObstacleDetour.MaxOffset`): a tree, or a row of trees, needing more is a U-turn obstacle for that pass. Design: `Plans/OBSTACLE_GO_AROUND_PLAN.md`; scenarios: `Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleScenarioTests.cs` (`GoAroundLegs`). - A pass that leaves the turn area and comes back within `UTurnCrossGap` metres (U-turn settings, "Drive across a headland gap up to"; 0 = off), across the headland only (inside the boundary, outside the headland line and every obstacle not driven through, and never through an obstacle's turn area, which is turned at as without the setting), is not turned at the gap: `GapCrossingEnd` in `YouTurnCreationService` walks on past it in both crossing searches, the turn is planned at the far end and the pass is driven through the gap with the sections off by headland section control (a narrow tip of the field, a notch in its edge). The next pass is taken from abreast of the gap's end (`gapResumeIndex`), since abreast of the tractor it can be outside the field. - The turn is built from the headland crossing **ahead** of the tractor (`FindTrackHeadlandIntersectionAhead`; one behind only past the last crossing). When no real turn fits, the simple fallback (a semicircle) is seated back along the pass until it keeps `UTurnDistanceFromBoundary` from the fence: to the headland crossing and up to 30 m into the field past it, starting ahead of the tractor (`SimpleFallback`, fork only: renatorgr/AgOpenWeb#130, #135). Only on a straight pass: the fallback runs straight from the tractor, so on a curve it is seated nowhere (as official) and built again as the tractor comes closer; seated to fit 190 m away on a boundary curve, it was never joined and the missed turn was made "from here". It is then used only if every point of it stays in the field, outside obstacles a turn may not drive through (`LeavesField`, #698); otherwise no turn, the creation-failed sound and "No U-turn fits inside the boundary at this end - take over manually". A hard boundary refuses a turn that would start under or behind the tractor. -- A turn tried again with the turn lines moved in (the implement over a hard fence, a landing on a bend, an obstacle in its legs: `CreateTurnPath`'s setback loop, which moves the whole fence in) is seated back from the end of the pass the first try found: walking back from that end, at the moved line's crossing nearest to it (`YouTurnCreationInput.TurnLineEndIndex`, `TurnLineEndOnRetry`; not after a gap crossed). Moved in, the fence's turn line also crossed the pass where the fence bulges in beside it, and the first crossing ahead was taken: on Tapada da Casa an 8 m front boom near the north fence moved the line in 5 m and the turn was made 260 m early, beside the east bulge, landing on the next pass inside its crop and leaving a strip of it unsprayed. Test: `TurnSetbackStaysAtEndTests`. Fork only. +- A turn tried again with the turn lines moved in (the implement over a hard fence, a landing on a bend, an obstacle in its legs: `CreateTurnPath`'s setback loop, which moves the whole fence in) is seated back from the end of the pass the first try found: walking back from that end, at the moved line's crossing nearest to it (`YouTurnCreationInput.TurnLineEndIndex`, `TurnLineEndOnRetry`; not after a gap crossed). Moved in, the fence's turn line also crossed the pass where the fence bulges in beside it, and the first crossing ahead was taken: on Tapada da Casa an 8 m front boom near the north fence moved the line in 5 m and the turn was made 260 m early, beside the east bulge, landing on the next pass inside its crop and leaving a strip of it unsprayed. Test: `TurnSetbackStaysAtEndTests`. Official since #828; not after a gap crossed is fork only. - The next pass must have room at this end: at least 20 m of it inside the cultivated area back from the end ahead (`IsPathLandable` / `LandingLength`), not just any piece of it inside the field; otherwise "End of field reached - no room on the next pass at this end" (#712). In this fork, when no pass has room but the current pass goes on inside the field past the headland ahead (a corner the headland clips off it, a gap), there is no turn and AutoSteer drives on across it: "No room to turn at this headland - driving on across it" (`GapAhead` / `DrivesOnAcrossGap`, renatorgr/AgOpenWeb#129; upstream left it out until after the v1.0 freeze). The pass must run on at least 10 m inside past the gap (`MinRunAfterGap`): a sliver or spike of the headland line is not more field. - Skip worked passes (the snake order): when the order's next pass has no room at this end, or its turn does not build, the turn goes onto the nearest pass still to do that has room, first on the side the order is going, then on the other side (one turn built per side; a pass at least the skip away before a nearer one; none further than twice the skip's step, else "End of field reached" and the operator decides), never against the side the operator picked with the U-turn arrow (`TurnOntoNearestOpenPass`; AgOpenGPS's `GetNextNotWorkedTrack` only steps outward on the turn's side past worked tracks, so the other side, passes nearer than the skip and the room and build checks are ours); the order is planned again from where it lands. The same nearest pass is tried first when the order's next pass still to do is further than twice the skip's step (`MaxFallbackStep`; the passes in between worked before): the turn went from 17R to 7R along the headland, 80 m, with 16R and 14R open beside the tractor (a tester's field, simulator); with none of them taking a turn, the order's pass is turned onto as before. Fork only. An end where no turn was made is remembered (`_failedEnd`) and not tried again on every cycle until the pass, the direction of travel, the nudge, the order, the field or headland, the skip, the U-turn arrow or a turn setting changes. The order is also planned again when the headland line or the outer boundary changes (`SnakeFieldStamp`), and forgotten with what was found about each pass when the line moves (`ForgetPassStatus`: Reference Line Nudge, an end trimmed; the moved line's pass N is another strip, and the old answers turned the snake past clean passes on Tapada da Casa). In the headland the snake looks for its next turn only heading out of the field (no cultivated ground ahead on the pass): a turn that ends in the headland leaves the tractor facing the field on its new pass, which is not the end of that pass. Fork only: a pass worked on the whole (half covered, `IsPassWorked`) is still to do at the end ahead when less than half of its 20 m inside the cultivated area next to that end is covered (`YouTurnPathingService.IsPassOpenAtThisEnd`, asked in `WorkedPasses` when a turn is planned, once per end): on a tester's field a pass 71 % covered, with its last 100 m at that end left, was turned past. - The end of a pass at a hard outer boundary with no turn planned or driven there ("End of field reached", no turn that fits, U-turns off): as the pivot gets 1 m into the headland from the cultivated area (`HeadlandEntryCheckAfter`), on the line and heading out (the pass leaves the outer boundary within 300 m with no 10 m of field on it first, `YouTurnPathingService.PassLeavesFieldAhead`), AutoSteer goes off with the alarm, "AutoSteer off: end of the pass and no U-turn here. Turn by hand." (`YouTurnStateMachine.EndOfPassWithoutTurn`, step 5b'''' in `GpsPipelineService`; once per entry; fork only). Left on, AutoSteer steered on along the line through the fence (as AgOpenGPS, which only sends geoStop): on Tapada da Casa the simulator drove on 2.8 km. Heading into the field, a headland strip with field past it, or a soft boundary do not count. This stop, the line facing out of the field below and a pass still open at this end measure from the headland line, where the crop ends, not from the turn line the U-turn uses (`TickContext.PassEndLine`, the line the pipeline arms them on: from the turn line a headland more than 10 m wider than the U-turn distance never stopped AutoSteer). @@ -601,8 +607,8 @@ Key parameters: - The headland's width plays no part in the turn: with a headland narrower than the turn is deep, the turn leaves its pass inside the headland line, still working ("Headland too narrow for this U-turn"). ## A front implement round a bend -AutoSteer steers the pivot on the line; an implement fixed `h` ahead of it (`Tool.IsToolFrontFixed`, -`Tool.HitchLength`) runs outside a bend of radius R by √(R² + h²) − R. `FrontToolLine.Steered` moves each point of +AutoSteer steers the pivot on the line; an implement fixed `h` ahead of it (`ToolMount.FrontFixed`, +`RigGeometry.AttachX`: the vehicle's front hitch plus the tool's working point from it) runs outside a bend of radius R by √(R² + h²) − R. `FrontToolLine.Steered` moves each point of the line steered (after the Go around bend, the same points one for one, so `PassUnfit` still lines up) towards the bend's centre by R − √(R² − h²), the pivot's bend never tighter than `Vehicle.MinTurningRadius`; R is measured over `max(h, 3 m)` either side of the point half way to the implement, headings over 2 m chords (a loop's ends overlap a diff --git a/Docs/FIELD_FILE_FORMATS.md b/Docs/FIELD_FILE_FORMATS.md index c7a8d7b78..669a4a1d7 100644 --- a/Docs/FIELD_FILE_FORMATS.md +++ b/Docs/FIELD_FILE_FORMATS.md @@ -68,7 +68,7 @@ Their points are `[longitude, latitude, heading]`: drop the third value, which i | Role | Geometry | Count | Properties | | --- | --- | --- | --- | -| `metadata` | Point at the origin | Exactly 1, first | `name`, `originLatitude`, `originLongitude`, `convergence`, `areaHectares`, `createdDate`, `lastModifiedDate` | +| `metadata` | Point at the origin | Exactly 1, first | `formatVersion`, `name`, `originLatitude`, `originLongitude`, `convergence`, `areaHectares`, `createdDate`, `lastModifiedDate` | | `outer-boundary` | Polygon, 1 ring, closed, with headings | 0 or 1 | `isDriveThrough`, `isHard`, `areaHectares` | | `inner-boundary` | Polygon, as above | 0 or more | `atObstacle`, `isDriveThrough`, `isHard`, `areaHectares`, `circleCenter`, `circleDiameter` | | `headland` | Polygon, as above | 0 or 1 | `isDriveThrough`, `isHard`, `areaHectares` (only geometry is read) | @@ -92,7 +92,14 @@ Property details: - **`headland-line`:** headland guidance paths built from the boundary. `moveDistance` in metres; `mode` and `aPointIndex` are AgOpenWeb builder state, preserve them as read. - **Mercator bounds:** EPSG:3857 metres of the image, present only when the image was captured from a web-map tile service. -A reader must ignore roles and properties it doesn't know, and a writer must keep them: AgOpenWeb saves each part on its own and leaves other features untouched. +A reader must ignore roles and properties it doesn't know, and a writer must keep them: AgOpenWeb saves each part on its own and leaves other features untouched, and writes a part's properties it doesn't read back as they were (a newer build's, another tool's). Members of a feature other than `type`, `geometry` and `properties` (an `id`, a `bbox`) are kept on the features AgOpenWeb doesn't rewrite; new data goes in `properties`. + +### Format version + +- **`formatVersion`:** integer, on the `metadata` feature; a file without one is version `1`. This document describes version `1`. +- **Adding** a role or a property does not change the version: an older reader ignores it and keeps it. +- **The version goes up** only when a change alters what something already in the file means (a property's unit or reference point, a role's geometry). Such a change says here how the earlier version is read. +- **A file in a newer version than the reader knows** is read as far as it understands it and is never written: AgOpenWeb opens such a field, tells the operator that changes to its boundaries, tracks, flags and headland are not saved, and writes nothing to its `field.geojson`. Another tool should do the same. ### Example @@ -107,6 +114,7 @@ The metadata and boundary come from a real field (ring shortened to 3 points); t "geometry": { "type": "Point", "coordinates": [-87.1802554, 32.59058587] }, "properties": { "role": "metadata", + "formatVersion": 1, "name": "Test1", "originLatitude": 32.59058587, "originLongitude": -87.1802554, @@ -170,6 +178,15 @@ A job's worked area is stored as square binary tiles on the field's plane grid: CamelCase JSON: `schemaVersion` (1), `id` (GUID), `fieldName`, `taskName` (same as the folder name), `workType`, `notes`, `startedAt`, `endedAt`, `lastOpenedAt` (ISO 8601), `status` (`"InProgress"`, `"Done"` or `"Abandoned"`), `distanceTraveledMeters`, `areaWorkedHectares`, `uTurnCount`. A `jobs//` folder without `job.json` is not listed as a job. +`rig` (optional; absent in jobs saved before it was kept): the machine the job was worked with, a copy of the profiles, not their names only, kept up to date while the job is open. As ISO 11783-10, each part is measured from the connector it hangs on, in metres, distances positive in their own direction: + +- `vehicle`: `profileName`, `type` (`"Tractor"`, `"Harvester"`, `"Articulated"`), `hitchType` (ISO 11783 coupling type, DDI 157: -1 not available, 0 unknown), and its connectors from the rear axle: `drawbarHitch` and `rearThreePointHitch` (the centre of the lower link points) behind it, `frontThreePointHitch` ahead of it (0: none set). +- `tools`: the tools in order from the vehicle. Each has `profileName`, `connector` (`"RearThreePoint"`, `"Drawbar"`, `"FrontThreePoint"` on the vehicle, or `"PreviousTool"`: the rear connector of the tool before it), `mount` (`"FrontFixed"`, `"RearFixed"`, `"Trailing"`, `"TBT"`), `hitchType`, then its distances from its connector: `workingPointFromHitch` (a fixed tool; negative is on the vehicle's side of the connector), `trailingHitchLength` (a trailed tool: connector to its wheels), `tankTrailingHitchLength` (TBT: connector to the tank's wheels), `trailingToolToPivotLength` (its wheels to the working point, + behind), and `offset` (working point to the right of the vehicle's centre line), `length`, `width`, `sectionWidths` (left to right), `rearConnectorFromHitch` (its own rear connector behind its connector, for a tool hung behind it; absent when it has none). A distance the tool's `mount` doesn't use is 0. + +AgOpenWeb works with one tool and writes a chain of one; a reader should accept more. A job reopened in AgOpenWeb gets the machine loaded then. + +`schemaVersion` follows the rules of `formatVersion` (section 3): members a reader doesn't know are kept by a writer, and the version goes up only for a change of meaning. A job whose `job.json` or coverage `manifest.json` is in a newer version than the reader knows is listed, but its coverage is not read and nothing is written to the job (AgOpenWeb tells the operator to start a new job). A `status` the reader doesn't know reads as `"InProgress"`. + ### manifest.json PascalCase JSON. A previous good copy is kept as `manifest.json.bak`. @@ -188,7 +205,7 @@ PascalCase JSON. A previous good copy is kept as `manifest.json.bak`. - `DisplayCellSize`: metres per display pixel. It names the display folder: `s` + round(cell × 10⁶), e.g. 0.35 m → `s350000`. - `TotalWorkedArea`: square metres. -- `MinE` … `MaxN`: the coverage grid's bounds in plane metres. A manifest is valid only if `SchemaVersion` is 1, `DisplayCellSize` > 0, `MaxE` > `MinE` and `MaxN` > `MinN`. +- `MinE` … `MaxN`: the coverage grid's bounds in plane metres. A manifest is valid only if `SchemaVersion` is 1, `DisplayCellSize` > 0, `MaxE` > `MinE` and `MaxN` > `MinN`. A manifest (or its `.bak`) with a higher `SchemaVersion` is a newer format, not damage: it is never set aside, and the coverage folder is neither read nor written. ### Tile addressing diff --git a/Shared/AgOpenWeb.Models/BoundaryPolygon.cs b/Shared/AgOpenWeb.Models/BoundaryPolygon.cs index 9b8cb7fde..ea669ddcb 100644 --- a/Shared/AgOpenWeb.Models/BoundaryPolygon.cs +++ b/Shared/AgOpenWeb.Models/BoundaryPolygon.cs @@ -16,6 +16,10 @@ namespace AgOpenWeb.Models; /// public class BoundaryPolygon { + /// The properties of this part's feature in field.geojson that this build does not + /// read (a newer build's, another tool's), written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; set; } + /// /// Boundary points (Easting, Northing, Heading in local coordinates) /// Minimum 3 points required for valid polygon diff --git a/Shared/AgOpenWeb.Models/Configuration/ConfigurationStore.cs b/Shared/AgOpenWeb.Models/Configuration/ConfigurationStore.cs index 151675dbf..82f0ba2aa 100644 --- a/Shared/AgOpenWeb.Models/Configuration/ConfigurationStore.cs +++ b/Shared/AgOpenWeb.Models/Configuration/ConfigurationStore.cs @@ -71,6 +71,12 @@ public string ActiveToolProfileName set => SetProperty(ref _activeToolProfileName, value); } + /// + /// What the last profile load converted from an older format (a tool's hitch, #547), for the + /// operator and the log; null when nothing was. The loader saves the converted files at once. + /// + public string? ProfileMigrationNote { get; set; } + private string _activeToolProfilePath = string.Empty; public string ActiveToolProfilePath { diff --git a/Shared/AgOpenWeb.Models/Configuration/ToolConfig.cs b/Shared/AgOpenWeb.Models/Configuration/ToolConfig.cs index 16bfa5f7e..9d358b575 100644 --- a/Shared/AgOpenWeb.Models/Configuration/ToolConfig.cs +++ b/Shared/AgOpenWeb.Models/Configuration/ToolConfig.cs @@ -47,15 +47,18 @@ public double Offset set => SetProperty(ref _offset, value); } - // Hitch (rigid tool): axle center -> implement working center (e.g. tiller rotary - // shaft, disc shaft). Tool-dependent, so it lives with the tool. Used ONLY by - // front/rear-fixed rigid tools. Trailing/TBT tools instead use Vehicle.HitchLength - // for the tractor hitch pin. Front tools use this positive (ahead), rear negative. - private double _hitchLength = 1.8; - public double HitchLength - { - get => _hitchLength; - set => SetProperty(ref _hitchLength, Math.Max(0, value)); + // Rigid (front/rear fixed) tool: its working point (e.g. a tiller's rotor, a boom) from the + // tractor hitch it is mounted on (the rear 3-point's lower links, or the front 3-point), measured + // away from the tractor: behind a rear hitch, ahead of a front one. Negative = the working point + // is on the tractor's side of the hitch. The tractor's hitch is the vehicle's + // (Vehicle.ThreePointHitchLength / FrontHitchLength), so one tool profile fits every tractor + // (#547; ISO 11783-10: the device element's offset from its connector). Trailing/TBT tools hang + // on the drawbar (Vehicle.HitchLength) and use their own lengths below. + private double _workingPointFromHitch; + public double WorkingPointFromHitch + { + get => _workingPointFromHitch; + set => SetProperty(ref _workingPointFromHitch, value); } // Fore-aft length of the implement body, from its attachment (hitch for rigid diff --git a/Shared/AgOpenWeb.Models/Configuration/VehicleConfig.cs b/Shared/AgOpenWeb.Models/Configuration/VehicleConfig.cs index c6be78cf7..ce1fa609c 100644 --- a/Shared/AgOpenWeb.Models/Configuration/VehicleConfig.cs +++ b/Shared/AgOpenWeb.Models/Configuration/VehicleConfig.cs @@ -108,11 +108,10 @@ public double ImuOffset set => SetProperty(ref _imuOffset, value); } - // Hitch (vehicle): rear axle center -> tractor hitch pin. This is the trailer - // attach point and is used ONLY by trailing/TBT tools to place the hitch. - // Rigid front/rear-fixed tools instead use Tool.HitchLength (axle -> implement - // working center, which is tool-dependent). Stored unsigned/positive; the tool - // position geometry applies the rear sign and takes Math.Abs(). + // The tractor's hitch points, distances from the centre of the rear axle (ISO 11783-10: the + // tractor's device reference point; its connectors). Kept positive, each in its own direction. + // + // Drawbar: the hitch pin, behind the axle. Trailing and TBT tools hang on it. private double _hitchLength = 1.8; public double HitchLength { @@ -120,6 +119,24 @@ public double HitchLength set => SetProperty(ref _hitchLength, value); } + // Rear 3-point hitch: the centre of the lower link points, behind the axle (ISO 11783-10's CRP + // of a three-point hitch). Rear-fixed tools hang on it. + private double _threePointHitchLength = 1.8; + public double ThreePointHitchLength + { + get => _threePointHitchLength; + set => SetProperty(ref _threePointHitchLength, Math.Max(0, value)); + } + + // Front 3-point hitch: the centre of its lower link points, ahead of the axle. Front-fixed tools + // hang on it; asked for only when a front tool is used. + private double _frontHitchLength; + public double FrontHitchLength + { + get => _frontHitchLength; + set => SetProperty(ref _frontHitchLength, Math.Max(0, value)); + } + // ISO 11783 hitch/coupling type code on the tractor side (-1 = not available, // 0 = unknown/default, 1..10 = specific ISO coupling standards). Stored as the // ISO integer; the UI shows the text description. Informational metadata for now. diff --git a/Shared/AgOpenWeb.Models/Configuration/VehicleToolLimits.cs b/Shared/AgOpenWeb.Models/Configuration/VehicleToolLimits.cs index 8f28eaf21..072db2e03 100644 --- a/Shared/AgOpenWeb.Models/Configuration/VehicleToolLimits.cs +++ b/Shared/AgOpenWeb.Models/Configuration/VehicleToolLimits.cs @@ -34,7 +34,9 @@ private enum Unit { Metre, Centimetre, Degree, Second, KmH, Percent, None } ["vehicle.antennaHeight"] = new(0, 10, Unit.Metre, "Antenna height"), ["vehicle.antennaPivot"] = new(-10, 10, Unit.Metre, "Antenna pivot"), ["vehicle.antennaOffset"] = new(-5, 5, Unit.Metre, "Antenna offset"), - ["vehicle.hitchLength"] = new(0, 40, Unit.Metre, "Hitch length"), + ["vehicle.hitchLength"] = new(0, 40, Unit.Metre, "Drawbar pin"), + ["vehicle.threePointHitchLength"] = new(0, 10, Unit.Metre, "Rear 3-point hitch"), + ["vehicle.frontHitchLength"] = new(0, 15, Unit.Metre, "Front 3-point hitch"), ["vehicle.imuPivot"] = new(-10, 10, Unit.Metre, "IMU pivot"), ["vehicle.imuHeight"] = new(0, 10, Unit.Metre, "IMU height"), ["vehicle.imuOffset"] = new(-5, 5, Unit.Metre, "IMU offset"), @@ -46,7 +48,7 @@ private enum Unit { Metre, Centimetre, Degree, Second, KmH, Percent, None } ["gps.fixToFixDistance"] = new(0.1, 10, Unit.Metre, "Fix-to-fix heading distance"), ["roll.rollZero"] = new(-30, 30, Unit.Degree, "Roll zero"), ["roll.rollFilter"] = new(0, 0.99, Unit.None, "Roll filter"), - ["tool.hitchLength"] = new(0, 30, Unit.Metre, "Working center"), + ["tool.workingPointFromHitch"] = new(-10, 30, Unit.Metre, "Hitch to working point"), ["tool.trailingHitchLength"] = new(0.1, 30, Unit.Metre, "Trailing hitch"), ["tool.tankTrailingHitchLength"] = new(0.1, 30, Unit.Metre, "Tank trailing hitch"), ["tool.trailingToolToPivotLength"] = new(-20, 20, Unit.Metre, "Pivot distance"), diff --git a/Shared/AgOpenWeb.Models/Field.cs b/Shared/AgOpenWeb.Models/Field.cs index 1190ebbea..e49018ff7 100644 --- a/Shared/AgOpenWeb.Models/Field.cs +++ b/Shared/AgOpenWeb.Models/Field.cs @@ -13,6 +13,10 @@ namespace AgOpenWeb.Models; /// public class Field { + /// The properties of this part's feature in field.geojson that this build does not + /// read (a newer build's, another tool's), written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = string.Empty; diff --git a/Shared/AgOpenWeb.Models/Field/Flag.cs b/Shared/AgOpenWeb.Models/Field/Flag.cs index 5b21f7962..310bfe314 100644 --- a/Shared/AgOpenWeb.Models/Field/Flag.cs +++ b/Shared/AgOpenWeb.Models/Field/Flag.cs @@ -14,6 +14,10 @@ public enum FlagColor public class Flag : ObservableObject { + /// The properties of this part's feature in field.geojson that this build does not + /// read (a newer build's, another tool's), written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; set; } + private FlagColor _flagColor; private string _name = ""; private string _notes = ""; diff --git a/Shared/AgOpenWeb.Models/FieldBackground.cs b/Shared/AgOpenWeb.Models/FieldBackground.cs index cebc29f78..37975bb09 100644 --- a/Shared/AgOpenWeb.Models/FieldBackground.cs +++ b/Shared/AgOpenWeb.Models/FieldBackground.cs @@ -18,6 +18,10 @@ public sealed record FieldBackground( { /// The image file name AgOpenWeb uses in a field folder. public const string DefaultImageFile = "background.png"; + + /// The properties of this part's feature in field.geojson that this build does not + /// read (a newer build's, another tool's), written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; init; } } /// Web-Mercator (EPSG:3857) bounds of an image, in metres. diff --git a/Shared/AgOpenWeb.Models/GeoJson/GeoJsonModels.cs b/Shared/AgOpenWeb.Models/GeoJson/GeoJsonModels.cs index b1742b0cf..f626eda1b 100644 --- a/Shared/AgOpenWeb.Models/GeoJson/GeoJsonModels.cs +++ b/Shared/AgOpenWeb.Models/GeoJson/GeoJsonModels.cs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; namespace AgOpenWeb.Models.GeoJson; @@ -18,6 +19,10 @@ public class GeoJsonFeatureCollection [JsonPropertyName("features")] public List Features { get; set; } = new(); + + /// Other members (a "bbox", another tool's), kept as they were. + [JsonExtensionData] + public Dictionary? Other { get; set; } } /// @@ -33,6 +38,11 @@ public class GeoJsonFeature [JsonPropertyName("properties")] public Dictionary Properties { get; set; } = new(); + + /// Other members (an "id", a "bbox"), kept as they were on a feature this build + /// does not rewrite. New data of AgOpenWeb's goes in . + [JsonExtensionData] + public Dictionary? Other { get; set; } } /// @@ -74,6 +84,7 @@ public static class GeoJsonTypes public static class FieldPropertyKeys { public const string Role = "role"; + public const string FormatVersion = "formatVersion"; public const string Name = "name"; public const string TrackType = "trackType"; public const string IsClosed = "isClosed"; diff --git a/Shared/AgOpenWeb.Models/Guidance/CurveProcessing.cs b/Shared/AgOpenWeb.Models/Guidance/CurveProcessing.cs index 6ee059dce..0867adc32 100644 --- a/Shared/AgOpenWeb.Models/Guidance/CurveProcessing.cs +++ b/Shared/AgOpenWeb.Models/Guidance/CurveProcessing.cs @@ -758,7 +758,7 @@ public static (List Points, double PercentRemoved, bool StartKept, bool En // its closing stretch pointing backwards, and a tractor a little off the line at the seam // was steered to a goal behind it and turned round. There it is left out, and the pass // goes from the last stretch's offset straight to the first's, as at any inside bend; on - // the outside it starts the arc round the seam (below). Fork only. + // the outside it starts the arc round the seam (below). bool seamInside = closed && refCount > 2 && SamePoint(originalPoints[0], originalPoints[refCount - 1]) && Math.IEEERemainder(originalPoints[0].Heading - originalPoints[refCount - 2].Heading, 2 * Math.PI) * offsetDistance > 0; @@ -843,7 +843,7 @@ public static (List Points, double PercentRemoved, bool StartKept, bool En // same collision test: untested, a seam at the tip of a narrow slot into the field (its // sides closer than twice the offset) put the whole arc round the tip into the pass, out // across the fence beyond it (8.7 m on a 12 m lap), and the pass crossed itself coming - // back. Fork only. + // back. if (closed && offsetPoints.Count > 2) { if (refCount > 2) diff --git a/Shared/AgOpenWeb.Models/Guidance/HeadlandLine.cs b/Shared/AgOpenWeb.Models/Guidance/HeadlandLine.cs index 656c522f9..9b1a12d88 100644 --- a/Shared/AgOpenWeb.Models/Guidance/HeadlandLine.cs +++ b/Shared/AgOpenWeb.Models/Guidance/HeadlandLine.cs @@ -37,6 +37,10 @@ public HeadlandLine() /// public class HeadlandPath { + /// The properties of this part's feature in field.geojson that this build does not + /// read (a newer build's, another tool's), written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; set; } + /// /// Track points (position + heading) /// diff --git a/Shared/AgOpenWeb.Models/Job/Job.cs b/Shared/AgOpenWeb.Models/Job/Job.cs index 0c8499625..98e1ba1fd 100644 --- a/Shared/AgOpenWeb.Models/Job/Job.cs +++ b/Shared/AgOpenWeb.Models/Job/Job.cs @@ -14,6 +14,10 @@ namespace AgOpenWeb.Models.Job; /// public class Job { + /// The members of its job.json that this build does not read (a newer build's), + /// written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; set; } + /// /// Stable identifier. Coverage and section-log files key off this on /// disk so renaming doesn't break references. @@ -73,4 +77,10 @@ public class Job /// Number of U-turns executed during this job. /// public int UTurnCount { get; set; } + + /// + /// The vehicle and the chain of tools the job was worked with (#547), a copy of the loaded + /// profiles kept up to date while the job is open; null for a job saved before it was kept. + /// + public JobRig? Rig { get; set; } } diff --git a/Shared/AgOpenWeb.Models/Job/JobRig.cs b/Shared/AgOpenWeb.Models/Job/JobRig.cs new file mode 100644 index 000000000..54743f254 --- /dev/null +++ b/Shared/AgOpenWeb.Models/Job/JobRig.cs @@ -0,0 +1,163 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Tool; + +namespace AgOpenWeb.Models.Job; + +/// +/// The machine a job was worked with: the vehicle's hitch points and the chain of tools hung on +/// them, in order from the vehicle (#547). It is a copy, not a profile name: a profile can be +/// edited or deleted later, and the job's coverage only makes sense with the tools that painted +/// it. As ISO 11783-10 chains a tractor's connectors and each implement's connector and elements, +/// each tool is measured from the connector it hangs on: the vehicle's, or the rear connector of +/// the tool before it. The web client works with one tool, so AgOpenWeb writes a chain of one; +/// a file from another program may carry more. +/// +public sealed class JobRig +{ + public RigVehicle Vehicle { get; set; } = new(); + + /// The tools in order from the vehicle; the first hangs on the vehicle. + public List Tools { get; set; } = new(); + + /// The vehicle and the one tool loaded now. + public static JobRig From(ConfigurationStore store) + { + var v = store.Vehicle; + var t = store.Tool; + var mount = RigGeometry.Mount(t); + int n = Math.Clamp(store.NumSections, 0, ToolConfig.MaxSections); + return new JobRig + { + Vehicle = new RigVehicle + { + ProfileName = store.ActiveVehicleProfileName ?? string.Empty, + Type = v.Type.ToString(), + HitchType = v.HitchType, + DrawbarHitch = v.HitchLength, + RearThreePointHitch = v.ThreePointHitchLength, + FrontThreePointHitch = v.FrontHitchLength, + }, + Tools = + { + new RigTool + { + ProfileName = store.ActiveToolProfileName ?? string.Empty, + Connector = mount switch + { + ToolMount.FrontFixed => RigConnector.FrontThreePoint, + ToolMount.RearFixed => RigConnector.RearThreePoint, + _ => RigConnector.Drawbar, + }, + Mount = mount, + HitchType = t.HitchType, + WorkingPointFromHitch = RigGeometry.IsRigid(mount) ? t.WorkingPointFromHitch : 0, + TrailingHitchLength = RigGeometry.IsRigid(mount) ? 0 : t.TrailingHitchLength, + TankTrailingHitchLength = mount == ToolMount.TBT ? t.TankTrailingHitchLength : 0, + TrailingToolToPivotLength = RigGeometry.IsRigid(mount) ? 0 : t.TrailingToolToPivotLength, + Offset = t.Offset, + Length = t.Length, + Width = store.ActualToolWidth, + SectionWidths = Enumerable.Range(0, n).Select(i => t.GetSectionWidth(i) / 100.0).ToList(), + }, + }, + }; + } + + /// The same machine, every value equal (to decide whether a job's copy is out of date). + public bool SameAs(JobRig? other) => + other != null && Vehicle.SameAs(other.Vehicle) + && Tools.Count == other.Tools.Count && Tools.Zip(other.Tools).All(p => p.First.SameAs(p.Second)); +} + +/// A vehicle's connectors, each its distance from the rear axle in metres (+ in its own direction). +public sealed class RigVehicle +{ + public string ProfileName { get; set; } = string.Empty; + + /// by name: Tractor, Harvester, Articulated. + public string Type { get; set; } = string.Empty; + + /// ISO 11783 coupling type (DDI 157) on the vehicle's side; -1 not available, 0 unknown. + public int HitchType { get; set; } + + /// Drawbar pin, behind the rear axle. + public double DrawbarHitch { get; set; } + + /// Rear 3-point hitch (the centre of the lower link points), behind the rear axle. + public double RearThreePointHitch { get; set; } + + /// Front 3-point hitch, ahead of the rear axle; 0 when the vehicle has none set. + public double FrontThreePointHitch { get; set; } + + public bool SameAs(RigVehicle o) => + ProfileName == o.ProfileName && Type == o.Type && HitchType == o.HitchType + && DrawbarHitch.Equals(o.DrawbarHitch) && RearThreePointHitch.Equals(o.RearThreePointHitch) + && FrontThreePointHitch.Equals(o.FrontThreePointHitch); +} + +/// Where a tool hangs: one of the vehicle's connectors, or the rear connector of the tool before it. +public enum RigConnector +{ + RearThreePoint, + Drawbar, + FrontThreePoint, + PreviousTool, +} + +/// +/// One tool of the chain, its distances in metres along the tool from the connector it hangs on, +/// as the tool profile keeps them. +/// +public sealed class RigTool +{ + public string ProfileName { get; set; } = string.Empty; + + public RigConnector Connector { get; set; } + + /// How it hangs: FrontFixed, RearFixed, Trailing or TBT. + public ToolMount Mount { get; set; } + + /// ISO 11783 coupling type (DDI 157) on the tool's side; -1 not available, 0 unknown. + public int HitchType { get; set; } + + /// A fixed tool: its working point from its connector (negative: on the vehicle's side of it). + public double WorkingPointFromHitch { get; set; } + + /// A trailed tool: its connector to its wheels. + public double TrailingHitchLength { get; set; } + + /// TBT: the connector to the tank's wheels (the tool then hangs on the tank). + public double TankTrailingHitchLength { get; set; } + + /// A trailed tool: its wheels to its working point (+ behind). + public double TrailingToolToPivotLength { get; set; } + + /// The working point to the side of the vehicle's centre line, + to the right. + public double Offset { get; set; } + + /// The tool's length along the direction of travel. + public double Length { get; set; } + + /// The working width: the sections together. + public double Width { get; set; } + + /// Each section's width, left to right. + public List SectionWidths { get; set; } = new(); + + /// A tool hung behind this one: its connector's distance behind this tool's connector; null when it has none. + public double? RearConnectorFromHitch { get; set; } + + public bool SameAs(RigTool o) => + ProfileName == o.ProfileName && Connector == o.Connector && Mount == o.Mount && HitchType == o.HitchType + && WorkingPointFromHitch.Equals(o.WorkingPointFromHitch) && TrailingHitchLength.Equals(o.TrailingHitchLength) + && TankTrailingHitchLength.Equals(o.TankTrailingHitchLength) && TrailingToolToPivotLength.Equals(o.TrailingToolToPivotLength) + && Offset.Equals(o.Offset) && Length.Equals(o.Length) && Width.Equals(o.Width) + && SectionWidths.SequenceEqual(o.SectionWidths) && Nullable.Equals(RearConnectorFromHitch, o.RearConnectorFromHitch); +} diff --git a/Shared/AgOpenWeb.Models/Tool/ImplementSweptPath.cs b/Shared/AgOpenWeb.Models/Tool/ImplementSweptPath.cs index a7b5a1464..325a680a1 100644 --- a/Shared/AgOpenWeb.Models/Tool/ImplementSweptPath.cs +++ b/Shared/AgOpenWeb.Models/Tool/ImplementSweptPath.cs @@ -28,8 +28,8 @@ public enum ToolMount /// Attachment type. /// Implement working width. /// Lateral offset of the tool centre (right positive). - /// Axle → tractor hitch pin (used by Trailing/TBT). - /// Axle → implement working centre (used by rigid tools). + /// Where the tool hangs along the tractor from the rear axle, + ahead: a fixed + /// tool's working point, a trailed tool's drawbar pin (). /// Hitch → tool pivot, along the tool. /// Tool pivot → tool working centre, AgOpenGPS's sign: positive with the tool behind its wheels. /// Hitch → tank pivot (TBT only). @@ -44,8 +44,7 @@ public readonly record struct ToolGeometry( ToolMount Mount, double Width, double Offset, - double VehicleHitchLength, - double ToolHitchLength, + double AttachX, double TrailingHitchLength, double TrailingToolToPivotLength, double TankTrailingHitchLength, @@ -101,12 +100,8 @@ public static ImplementSweptPathResult Compute(IReadOnlyList pivotPath, To double halfWidth = geom.Width / 2.0; - // Hitch distance + sign: rigid tools measure axle→implement centre, - // trailing/TBT measure axle→tractor pin. Behind for everything except - // a front-fixed tool. - bool rigid = geom.Mount == ToolMount.FrontFixed || geom.Mount == ToolMount.RearFixed; - double hitchDistance = Math.Abs(rigid ? geom.ToolHitchLength : geom.VehicleHitchLength); - if (geom.Mount != ToolMount.FrontFixed) hitchDistance = -hitchDistance; + // The point the tool hangs on, as the tool position has it (RigGeometry). + double hitchDistance = geom.AttachX; Vec3 lastToolPivot = default; Vec3 lastTank = default; diff --git a/Shared/AgOpenWeb.Models/Tool/RigGeometry.cs b/Shared/AgOpenWeb.Models/Tool/RigGeometry.cs new file mode 100644 index 000000000..ee7aceec1 --- /dev/null +++ b/Shared/AgOpenWeb.Models/Tool/RigGeometry.cs @@ -0,0 +1,117 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using AgOpenWeb.Models.Base; +using AgOpenWeb.Models.Configuration; + +namespace AgOpenWeb.Models.Tool +{ + /// + /// Where the tool hangs on the tractor: the one place that turns the vehicle's and the tool's + /// settings into the tool's mount and the point it is attached at. The tool position (sections, + /// coverage), the map's implement, the U-turn's swept path and the settings that invalidate a + /// remembered U-turn all read it, so they cannot disagree (they each had their own copy, and + /// the copies differed on which flag won when a profile set two). + /// Distances follow ISO 11783-10's device axes: x along the tractor from the centre of the + /// rear axle, positive ahead (NonTracked/HITCH_MODEL_PLAN.md). + /// + public static class RigGeometry + { + /// + /// The tool's mount from its type flags. The flags are set together by + /// , but a profile sets them one by one, so more than + /// one can be on. As the tool position always read them: a fixed flag makes the tool fixed, + /// and it is ahead only with the front flag alone; otherwise TBT before trailing. None set + /// is a rear-fixed tool. + /// + public static ToolMount Mount(ToolConfig tool) + { + bool trailed = tool.IsToolTrailing || tool.IsToolTBT; + if (tool.IsToolFrontFixed && !tool.IsToolRearFixed && !trailed) return ToolMount.FrontFixed; + if (tool.IsToolFrontFixed || tool.IsToolRearFixed) return ToolMount.RearFixed; + if (tool.IsToolTBT) return ToolMount.TBT; + if (tool.IsToolTrailing) return ToolMount.Trailing; + return ToolMount.RearFixed; + } + + /// Front or rear fixed: the tool follows the tractor's heading. + public static bool IsRigid(ToolMount mount) => mount is ToolMount.FrontFixed or ToolMount.RearFixed; + + /// + /// Where the tool is placed along the tractor from the rear axle (+ ahead), as ISO + /// 11783-10 chains it: the tractor's connector, then the tool from it. A fixed tool: its + /// working point, the vehicle's 3-point hitch (front or rear) plus the tool's distance from + /// it, away from the tractor. A trailed or TBT tool: the drawbar pin, behind the axle. + /// + public static double AttachX(ToolMount mount, double drawbar, double threePoint, double frontHitch, double workingPointFromHitch) => + mount switch + { + ToolMount.FrontFixed => Math.Abs(frontHitch) + workingPointFromHitch, + ToolMount.RearFixed => -(Math.Abs(threePoint) + workingPointFromHitch), + _ => -Math.Abs(drawbar), + }; + + /// + public static double AttachX(VehicleConfig vehicle, ToolConfig tool) => + AttachX(Mount(tool), vehicle.HitchLength, vehicle.ThreePointHitchLength, vehicle.FrontHitchLength, tool.WorkingPointFromHitch); + + /// + /// A fixed tool's working point from the rear axle, the way profiles stored it before the + /// hitch points moved to the vehicle (format 2's Tool.HitchLength, positive, ahead + /// for a front tool): what an older build reads back from a newer profile. + /// + public static double AxleToWorkingPoint(VehicleConfig vehicle, ToolConfig tool) => + Mount(tool) == ToolMount.FrontFixed + ? Math.Abs(vehicle.FrontHitchLength) + tool.WorkingPointFromHitch + : Math.Abs(vehicle.ThreePointHitchLength) + tool.WorkingPointFromHitch; + + /// The point along the tractor (+ ahead) from . + public static Vec3 AlongTractor(Vec3 pivot, double heading, double x) => + new(pivot.Easting + Math.Sin(heading) * x, pivot.Northing + Math.Cos(heading) * x, heading); + + /// + /// How far a trailed tool's working centre is behind what pulls it (the hitch, or the TBT + /// tank): the trailing hitch length plus AgOpenGPS's pivot distance + /// (setTool_trailingToolToPivotLength, the Tool panel's Behind / Ahead): positive with the + /// tool behind its wheels, negative with it ahead. + /// + public static double ToolBehindPivot(ToolConfig tool) => tool.TrailingHitchLength + tool.TrailingToolToPivotLength; + + /// + /// A trailed or TBT tool's working centre, from the tractor's hitch and the headings of the + /// TBT tank and of the tool. + /// + public static (double Easting, double Northing) TrailedToolCentre( + double hitchEasting, double hitchNorthing, double tankHeading, double toolHeading, ToolConfig tool) + { + if (tool.IsToolTBT) + { + hitchEasting -= Math.Sin(tankHeading) * tool.TankTrailingHitchLength; + hitchNorthing -= Math.Cos(tankHeading) * tool.TankTrailingHitchLength; + } + double behind = ToolBehindPivot(tool); + return (hitchEasting - Math.Sin(toolHeading) * behind, hitchNorthing - Math.Cos(toolHeading) * behind); + } + + /// The implement's geometry for . + public static ToolGeometry Geometry(VehicleConfig vehicle, ToolConfig tool, double width) => new( + Mount: Mount(tool), + Width: width, + Offset: tool.Offset, + AttachX: AttachX(vehicle, tool), + TrailingHitchLength: tool.TrailingHitchLength, + TrailingToolToPivotLength: tool.TrailingToolToPivotLength, + TankTrailingHitchLength: tool.TankTrailingHitchLength, + Length: tool.Length); + + /// + /// A stamp of every setting that moves the tool relative to the tractor: a value worked out + /// from the tool's position (a U-turn tried and refused at this end) is stale once it changes. + /// + public static int Stamp(VehicleConfig vehicle, ToolConfig tool) => HashCode.Combine( + Mount(tool), AttachX(vehicle, tool), tool.TrailingHitchLength, + tool.TrailingToolToPivotLength, tool.TankTrailingHitchLength, tool.Offset, tool.Length); + } +} diff --git a/Shared/AgOpenWeb.Models/Track/Track.cs b/Shared/AgOpenWeb.Models/Track/Track.cs index 58789e7c7..3e1339241 100644 --- a/Shared/AgOpenWeb.Models/Track/Track.cs +++ b/Shared/AgOpenWeb.Models/Track/Track.cs @@ -56,6 +56,10 @@ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } + /// The properties of this part's feature in field.geojson that this build does not + /// read (a newer build's, another tool's), written back as they were (FIELD_FILE_FORMATS.md). + public IReadOnlyDictionary? UnknownProperties { get; set; } + /// /// Track name for display and file storage. /// diff --git a/Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationInput.cs b/Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationInput.cs index b18a21f28..233a87f08 100644 --- a/Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationInput.cs +++ b/Shared/AgOpenWeb.Models/YouTurn/YouTurnCreationInput.cs @@ -65,7 +65,7 @@ public class YouTurnCreationInput /// again with the turn lines moved in (the clearance from a hard fence, a landing on a bend): /// the turn is then seated back from that end, at the moved turn line's crossing nearest to /// it. Null on the first try. Moved in, the fence's turn line can cross the pass much earlier - /// where the fence bulges in beside it, and the turn was made there. Fork only. + /// where the fence comes in beside it, and the turn was made there. /// public int? TurnLineEndIndex { get; set; } diff --git a/Shared/AgOpenWeb.RemoteServer/Contracts.cs b/Shared/AgOpenWeb.RemoteServer/Contracts.cs index 0fe305a04..96d913cf0 100644 --- a/Shared/AgOpenWeb.RemoteServer/Contracts.cs +++ b/Shared/AgOpenWeb.RemoteServer/Contracts.cs @@ -483,7 +483,7 @@ public record DisplayConfigDto( /// Tool/Implement tab (ConfigStore.Tool + NumSections). Type: 0 front, 1 rear, /// 2 TBT, 3 trailing. Arrays fixed-size (16 widths/colours, 9 zone ranges). public record ToolConfigDto( - int Type, int HitchType, double HitchLength, double TrailingHitchLength, + int Type, int HitchType, double WorkingPointFromHitch, double TrailingHitchLength, double TankTrailingHitchLength, double Length, double LookAheadOn, double LookAheadOff, double TurnOffDelay, double Offset, double Overlap, double TrailingToolToPivotLength, @@ -550,7 +550,9 @@ public record VehicleConfigDto( string Name, int Type, // VehicleType: 0 Tractor, 1 Harvester, 2 FourWD int HitchType, // code; index into HitchTypeOptions = HitchType + 1 - double HitchLength, + double HitchLength, // drawbar pin behind the rear axle + double ThreePointHitchLength, // rear 3-point (lower links) behind the rear axle + double FrontHitchLength, // front 3-point ahead of the rear axle; 0 = none double Wheelbase, double TrackWidth, double AntennaPivot, diff --git a/Shared/AgOpenWeb.RemoteServer/SceneProjector.cs b/Shared/AgOpenWeb.RemoteServer/SceneProjector.cs index 7f2cc7c14..419034622 100644 --- a/Shared/AgOpenWeb.RemoteServer/SceneProjector.cs +++ b/Shared/AgOpenWeb.RemoteServer/SceneProjector.cs @@ -824,21 +824,28 @@ public ConfigDto BuildConfig() var t = _config.Tool; var g = _config.Guidance; var m = _config.Machine; - int toolType = t.IsToolFrontFixed ? 0 : t.IsToolRearFixed ? 1 : t.IsToolTBT ? 2 : 3; + // The mount the tool position uses (RigGeometry), as the panel's type: 0 front, 1 rear, 2 TBT, 3 trailing. + int toolType = AgOpenWeb.Models.Tool.RigGeometry.Mount(t) switch + { + AgOpenWeb.Models.Tool.ToolMount.FrontFixed => 0, + AgOpenWeb.Models.Tool.ToolMount.RearFixed => 1, + AgOpenWeb.Models.Tool.ToolMount.TBT => 2, + _ => 3, + }; // Send all MaxSections widths/colours (not 16) so sections 17-64 get their data too (#41). var widths = new double[ToolConfig.MaxSections]; for (int i = 0; i < widths.Length; i++) widths[i] = t.GetSectionWidth(i); var colors = new int[ToolConfig.MaxSections]; for (int i = 0; i < colors.Length; i++) colors[i] = (int)t.GetSectionColor(i); var zones = new int[9]; for (int i = 0; i < 9; i++) zones[i] = t.GetZoneEndSection(i); var pins = new int[24]; for (int i = 0; i < 24; i++) pins[i] = (int)m.GetPinAssignment(i); return new ConfigDto( - new VehicleConfigDto(v.Name, (int)v.Type, v.HitchType, v.HitchLength, v.Wheelbase, + new VehicleConfigDto(v.Name, (int)v.Type, v.HitchType, v.HitchLength, v.ThreePointHitchLength, v.FrontHitchLength, v.Wheelbase, v.TrackWidth, v.AntennaPivot, v.AntennaHeight, v.AntennaOffset, v.ImuPivot, v.ImuHeight, v.ImuOffset), new GpsConfigDto(c.IsDualGps, c.DualHeadingOffset, c.DualReverseDistance, c.AutoDualFix, c.DualSwitchSpeed, c.MinGpsStep, c.FixToFixDistance, c.HeadingFusionWeight, c.ReverseDetection, c.RtkLostAlarm, c.RtkLostAction, c.RtkHold), new RollConfigDto(a.RollZero, a.RollFilter, a.IsRollInvert), - new ToolConfigDto(toolType, t.HitchType, t.HitchLength, t.TrailingHitchLength, + new ToolConfigDto(toolType, t.HitchType, t.WorkingPointFromHitch, t.TrailingHitchLength, t.TankTrailingHitchLength, t.Length, t.LookAheadOnSetting, t.LookAheadOffSetting, t.TurnOffDelay, t.Offset, t.Overlap, t.TrailingToolToPivotLength, t.IsSectionsNotZones, _config.NumSections, t.DefaultSectionWidth, widths, @@ -933,20 +940,20 @@ public long ConfigFingerprint() h = h * 31 + (c.IsDualGps ? 1 : 0) * 7 + (c.AutoDualFix ? 1 : 0) * 11 + (c.ReverseDetection ? 1 : 0) * 13 + (c.RtkLostAlarm ? 1 : 0) * 17 + c.RtkLostAction * 19 + (a.IsRollInvert ? 1 : 0) * 23 + (c.RtkHold ? 1 : 0) * 29; - foreach (var d in new[] { v.HitchLength, v.Wheelbase, v.TrackWidth, v.AntennaPivot, + foreach (var d in new[] { v.HitchLength, v.ThreePointHitchLength, v.FrontHitchLength, v.Wheelbase, v.TrackWidth, v.AntennaPivot, v.AntennaHeight, v.AntennaOffset, c.DualHeadingOffset, c.DualReverseDistance, c.DualSwitchSpeed, c.MinGpsStep, c.FixToFixDistance, c.HeadingFusionWeight, a.RollZero, a.RollFilter }) h = h * 31 + d.GetHashCode(); // Tool / U-Turn / Tram / Machine (so Tool-dialog edits re-send the frame). var t = _config.Tool; var g = _config.Guidance; var mc = _config.Machine; - h = h * 31 + (t.IsToolFrontFixed ? 1 : t.IsToolRearFixed ? 2 : t.IsToolTBT ? 3 : 4); + h = h * 31 + (int)AgOpenWeb.Models.Tool.RigGeometry.Mount(t) + 1; h = h * 31 + t.HitchType + t.Zones * 31 + _config.NumSections * 7 + (t.IsSectionsNotZones ? 1 : 0) + (t.IsMultiColoredSections ? 2 : 0) + (t.IsSectionOffWhenOut ? 4 : 0) + (t.IsHeadlandSectionControl ? 8 : 0) + t.MinCoverage + (t.IsWorkSwitchEnabled ? 16 : 0) + (t.IsWorkSwitchActiveLow ? 32 : 0) + (t.IsWorkSwitchManualSections ? 64 : 0) + (t.IsSteerSwitchEnabled ? 128 : 0) + (t.IsSteerSwitchManualSections ? 256 : 0); - foreach (var d in new[] { t.HitchLength, t.TrailingHitchLength, t.TankTrailingHitchLength, t.Length, + foreach (var d in new[] { t.WorkingPointFromHitch, t.TrailingHitchLength, t.TankTrailingHitchLength, t.Length, t.LookAheadOnSetting, t.LookAheadOffSetting, t.TurnOffDelay, t.Offset, t.Overlap, t.TrailingToolToPivotLength, t.DefaultSectionWidth, t.SlowSpeedCutoff, t.CoverageMargin, g.UTurnExtension, g.UTurnRadius, g.UTurnDistanceFromBoundary, g.GoAroundRadius, g.GoAroundMargin, diff --git a/Shared/AgOpenWeb.RemoteServer/WireCodec.cs b/Shared/AgOpenWeb.RemoteServer/WireCodec.cs index d588188e2..408a4bb04 100644 --- a/Shared/AgOpenWeb.RemoteServer/WireCodec.cs +++ b/Shared/AgOpenWeb.RemoteServer/WireCodec.cs @@ -359,6 +359,8 @@ public static byte[] EncodeConfig(ConfigDto c) w.Write(v.Type); // i32 w.Write(v.HitchType); // i32 w.Write(v.HitchLength); // f64 + w.Write(v.ThreePointHitchLength); + w.Write(v.FrontHitchLength); w.Write(v.Wheelbase); w.Write(v.TrackWidth); w.Write(v.AntennaPivot); @@ -388,7 +390,7 @@ public static byte[] EncodeConfig(ConfigDto c) var t = c.Tool; w.Write(t.Type); // i32 w.Write(t.HitchType); - w.Write(t.HitchLength); // f64 + w.Write(t.WorkingPointFromHitch); // f64 w.Write(t.TrailingHitchLength); w.Write(t.TankTrailingHitchLength); w.Write(t.Length); diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/cs.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/cs.json index 1235e0522..20e0b98f5 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/cs.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/cs.json @@ -325,7 +325,6 @@ "Headland on/off": "Souvratě zap/vyp", "Help & Info": "Nápověda a informace", "Hitch": "Závěs", - "Hitch length": "Délka závěsu", "Host IPs": "Hostovací IP", "Hotkeys": "Klávesové zkratky", "How fast GPS corrects the IMU heading": "Jak rychle GPS koriguje kurz z IMU", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/de.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/de.json index 882f374d0..75dc0bbc0 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/de.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/de.json @@ -425,7 +425,6 @@ "Green": "Grün", "Headland on/off": "Vorgewende ein/aus", "Help & Info": "Hilfe & Info", - "Hitch length": "Distanz zu Anhängevorrichtung", "Implement length": "Länge Anbaugerät", "Import \"{file}\" as the field boundary? This replaces the current outer boundary.": "Importiere \"{file}\" als Feldgrenze? Diese ersetzt die aktuelle Feldgrenze.", "Import boundary from a KML/KMZ file": "Importiere Feldgrenze von KML/KMZ Datei", @@ -724,7 +723,6 @@ "Toggle recorded-path display": "Anzeige des aufgezeichneten Pfads ein-/ausblenden", "Type a name, then New": "Tippe einen Namen ein, dann tippe auf Neu", "Wheel Track": "*Spurweite", - "Working center": "Arbeitspunkt", "Integral gain (Ki)": "Integral Faktor", "Gain / PWM": "Faktor / PWM", "Steering + section control": "Lenkung + Teilbreitenschaltung", @@ -996,7 +994,7 @@ "Passes": "*Durchgänge", "Passes (0 = all)": "*Überspringen (0=alle)", "{n} pass": "*{n} überspringen", - "First pass": "*Erster Durchgang", + "First pass": "Erster Durchgang", "Act": "Ist", "Actual remaining": "Effektiv verbleibend", "Actual": "*Effektiv netto", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json index 49db953db..d16b467b9 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/en.json @@ -300,6 +300,7 @@ "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", + "Drawbar pin": "Drawbar pin", "Drive AB": "Drive AB", "Drive across a headland gap up to": "Drive across a headland gap up to", "Drive around field to record boundary": "Drive around field to record boundary", @@ -402,7 +403,9 @@ "From Field Boundary": "From Field Boundary", "From ISO-XML": "From ISO-XML", "From KML": "From KML", + "Front 3-point hitch": "Front 3-point hitch", "Front Fixed": "Front Fixed", + "Front hitch {h} (vehicle) + {d} = {t} ahead of the rear axle": "Front hitch {h} (vehicle) + {d} = {t} ahead of the rear axle", "Front to rear axle distance": "Front to rear axle distance", "Full": "Full", "Full Setup": "Full Setup", @@ -463,7 +466,7 @@ "High pressure": "High pressure", "Hitch": "Hitch", "Hitch / Wheelbase / Track": "Hitch / Wheelbase / Track", - "Hitch length": "Hitch length", + "Hitch to working point": "Hitch to working point", "Hold the line when RTK is lost": "Hold the line when RTK is lost", "Host": "Host", "Host IPs": "Host IPs", @@ -817,6 +820,8 @@ "Rate": "Rate", "Ready": "Ready", "Ready to finish": "Ready to finish", + "Rear 3-point hitch": "Rear 3-point hitch", + "Rear 3-point hitch {h} (vehicle) + {d} = {t} behind the rear axle": "Rear 3-point hitch {h} (vehicle) + {d} = {t} behind the rear axle", "Rear Fixed": "Rear Fixed", "Receiver and antenna descriptor": "Receiver and antenna descriptor", "Receiver: {fix}, correction age {age} s": "Receiver: {fix}, correction age {age} s", @@ -1195,7 +1200,6 @@ "With this on, AutoSteer on the simulator makes the steer module turn the real wheels or motor. Use it for a bench test only, never on a tractor with the engine running. It goes off by itself when the simulator is turned off.": "With this on, AutoSteer on the simulator makes the steer module turn the real wheels or motor. Use it for a bench test only, never on a tractor with the engine running. It goes off by itself when the simulator is turned off.", "Work switch": "Work switch", "Worked": "Worked", - "Working center": "Working center", "XTE": "XTE", "XTE Chart": "XTE Chart", "Yaw rate": "Yaw rate", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/hu.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/hu.json index 75b206e17..cde88d2fd 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/hu.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/hu.json @@ -340,7 +340,6 @@ "Headland on/off": "Forgó be/ki", "Help & Info": "Segítség & Információ", "Hitch / Wheelbase / Track": "Horog / Tengelytáv / Nyomtáv", - "Hitch length": "Horog hossz", "Host": "Szerver", "Host IPs": "Szerver IPk", "Hotkeys": "Gyorsbillentyűk", @@ -701,7 +700,6 @@ "Whole Boundary": "Teljes határ", "Whole-boundary headland is not point-edited": "A teljes határ menti forgó pontonként nem szerkeszthető", "Work switch": "Munkakapcsoló", - "Working center": "Munka középpont", "XTE": "XTE", "Yellow": "Sárga", "Yes": "Igen", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lt.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lt.json index 1f975b9b0..39563de4b 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lt.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lt.json @@ -367,7 +367,6 @@ "Headland on/off": "Galulaukės įjungta/išjungta", "Help & Info": "Pagalba ir info", "Hitch / Wheelbase / Track": "Sukabinimo įtaisas / Ratų bazė / Vėžė", - "Hitch length": "Vilkties įtaiso ilgis", "Host": "Priegloba", "Host IPs": "Prieglogos IP adresai", "Hotkeys": "Spartieji klavišai", @@ -770,7 +769,6 @@ "Whole Boundary": "Visa riba", "Whole-boundary headland is not point-edited": "Visos ribos pakraštys nėra redaguojamas taškiniu būdu", "Work switch": "Darbinis jungtukas", - "Working center": "Dirbimo centras", "XTE": "XTE navigacijos nuokrypis", "Yaw rate": "Posūkio greitis", "Yellow": "Geltona", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lv.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lv.json index 209e90d49..b2e1639df 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lv.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/lv.json @@ -73,7 +73,6 @@ "Finish the U-turn first": "Vispirms pabeidziet U-pagriezienu", "Fix to fix": "Fiksēts", "Hitch": "Sakabe", - "Hitch length": "Sakabes garums", "Hotkeys": "Karstie taustiņi", "Hydraulic": "Hidraulika", "Imperial": "Imperiālā sistēma", @@ -770,7 +769,6 @@ "Whole Boundary": "Visa robeža", "Whole-boundary headland is not point-edited": "Visas robežas apgriešanās joslu nevar rediģēt ar punktiem", "Work switch": "Darba slēdzis", - "Working center": "Darba centrs", "XTE": "XTE", "XTE Chart": "XTE diagramma", "Yaw rate": "Leņķiskais ātrums", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/nl.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/nl.json index 8992f95ca..5f066b61d 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/nl.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/nl.json @@ -2,7 +2,6 @@ "Steer response hold": "Stuurreactie vastgezet", "Reset Default {kind} profile?": "Zet Standaardprofiel {kind} terug?", "Move the line left by the step": "Verplaats de lijn een stapafstand naar links", - "Working center": "Werkcentrum", "Check the address and the internet connection.": "Controleer het adres en de internetverbinding.", "{system} observations (MSM{n})": "{system} observaties (MSM{n})", "Web Cards": "Webkaarten", @@ -638,7 +637,6 @@ "Help & Info": "Help & Info", "Hitch": "Hef", "Hitch / Wheelbase / Track": "Hef / Wielbasis / Spoor", - "Hitch length": "Heflengte", "Host": "Host", "Host IPs": "Host-IPs", "Hotkeys": "Sneltoetsen", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pl.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pl.json index e70e9447d..8ba7ed616 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pl.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pl.json @@ -324,7 +324,6 @@ "Headland on/off": "Uwrocie wł./wył.", "Help & Info": "Pomoc i informacje", "Hitch / Wheelbase / Track": "Zaczep / Rozstaw osi / Rozstaw kół", - "Hitch length": "Długość zaczepu", "Host": "Serwer", "Host IPs": "Adresy IP urządzenia", "Hotkeys": "Skróty klawiszowe", @@ -687,7 +686,6 @@ "Whole Boundary": "Cała granica", "Whole-boundary headland is not point-edited": "Uwrocie całościowe nie podlega edycji punktowej", "Work switch": "Przycisk pracy", - "Working center": "Środek roboczy", "XTE": "XTE", "Yellow": "Żółty", "Yes": "Tak", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pt.json b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pt.json index f643f4e23..8eedc6039 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pt.json +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/i18n/pt.json @@ -348,7 +348,6 @@ "Headland on/off": "Rumo liga/desliga", "Help & Info": "Ajuda e informações", "Hitch / Wheelbase / Track": "Engate / Distância entre eixos / Bitola", - "Hitch length": "Comprimento do engate", "Host": "Hospedagem", "Host IPs": "IPs de hospedagem", "Hotkeys": "Teclas de atalho", @@ -712,7 +711,6 @@ "Whole Boundary": "Borda completa", "Whole-boundary headland is not point-edited": "A borda completa não está sujeita a edição por pontos.", "Work switch": "Trocar de trabalho", - "Working center": "Central de serviço", "XTE": "XTE", "Yellow": "Amarelo", "Yes": "Sim", diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitch3ptFront.png b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitch3ptFront.png new file mode 100644 index 000000000..2f212a26f Binary files /dev/null and b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitch3ptFront.png differ diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitch3ptRear.png b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitch3ptRear.png new file mode 100644 index 000000000..3b7af0fd1 Binary files /dev/null and b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitch3ptRear.png differ diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitchPageFront.png b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitchPageFront.png deleted file mode 100644 index a015fcb90..000000000 Binary files a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitchPageFront.png and /dev/null differ diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitchPageRear.png b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitchPageRear.png deleted file mode 100644 index 20716b3c5..000000000 Binary files a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/ToolHitchPageRear.png and /dev/null differ diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/VehicleHitch3ptFront.png b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/VehicleHitch3ptFront.png new file mode 100644 index 000000000..b3b37c0a0 Binary files /dev/null and b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/VehicleHitch3ptFront.png differ diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/VehicleHitch3ptRear.png b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/VehicleHitch3ptRear.png new file mode 100644 index 000000000..139424527 Binary files /dev/null and b/Shared/AgOpenWeb.RemoteServer/wwwroot/icons/VehicleHitch3ptRear.png differ diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html index 2ae461568..25a00ee51 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/index.html @@ -385,7 +385,9 @@
- + + +
@@ -505,7 +507,8 @@
- + +
diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/config-panels.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/config-panels.js index 189092dfc..51caee903 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/config-panels.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/config-panels.js @@ -45,7 +45,9 @@ function populateToolCfg(force) { populateCfgControls(tcPanel, force); if (document.activeElement !== tcHitchSel) tcHitchSel.value = t.hitchType; const hi = document.getElementById('tc-img-hitch'); - const hp = '/icons/' + (['ToolHitchPageFront', 'ToolHitchPageRear', 'ToolHitchPageTBT', 'ToolHitchPageTrailing'][t.type] || 'ToolHitchPageRear') + '.png'; + // A fixed tool is measured from the tractor's lower-link pins (the vehicle has the pins' distance + // from the rear axle, #547); a trailed one from the drawbar pin to its wheels. + const hp = '/icons/' + (['ToolHitch3ptFront', 'ToolHitch3ptRear', 'ToolHitchPageTBT', 'ToolHitchPageTrailing'][t.type] || 'ToolHitch3ptRear') + '.png'; if (!hi.src.endsWith(hp)) hi.src = hp; // The pivot picture shows the choice, as AgOpenGPS's buttons do: the tool ahead of its wheels, // or behind them (also for Zero). @@ -54,7 +56,17 @@ function populateToolCfg(force) { if (!pi.src.endsWith(pp)) pi.src = pp; // Conditional visibility by tool type (0 front, 1 rear, 2 TBT, 3 trailing). const trailing = t.type === 3, tbt = t.type === 2, trailtbt = trailing || tbt; - tcShow('rigid', !trailing); tcShow('trailtbt', trailtbt); tcShow('tbt', tbt); tcShow('notrailtbt', !trailtbt); + tcShow('rigid', !trailtbt); tcShow('trailtbt', trailtbt); tcShow('tbt', tbt); tcShow('notrailtbt', !trailtbt); + // A fixed tool hangs on the tractor's 3-point hitch (the vehicle's): the sum says where its + // working point is from the rear axle (#547). + if (!trailtbt) { + const front = t.type === 0; + const hitch = (front ? cfgGet('vehicle.frontHitchLength') : cfgGet('vehicle.threePointHitchLength')) || 0; + const total = hitch + (t.workingPointFromHitch || 0); + document.getElementById('tc-hitchsum').textContent = front + ? tr('Front hitch {h} (vehicle) + {d} = {t} ahead of the rear axle', { h: fmtUnit(hitch, 'm', 2), d: fmtUnit(t.workingPointFromHitch || 0, 'm', 2), t: fmtUnit(total, 'm', 2) }) + : tr('Rear 3-point hitch {h} (vehicle) + {d} = {t} behind the rear axle', { h: fmtUnit(hitch, 'm', 2), d: fmtUnit(t.workingPointFromHitch || 0, 'm', 2), t: fmtUnit(total, 'm', 2) }); + } tcShow('individual', t.isSectionsNotZones); tcShow('zones', !t.isSectionsNotZones); tcShow('multicolor', t.isMultiColoredSections); tcShow('singlecolor', !t.isMultiColoredSections); tcShow('worksw', t.isWorkSwitchEnabled); tcShow('steersw', t.isSteerSwitchEnabled); diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js index 6bc511c0c..517e2360b 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/map-draw.js @@ -276,7 +276,8 @@ function drawExtraGuidelinesSk(canvas) { const cmds = [], outer = outerBoundary(); for (const gl of scene.guideLines || []) { if (!gl || gl.length < 2 || (outer && !entersField(gl, outer))) continue; - lineCmds(drawnPts(extendAbLine(gl)), false, cmds); + const pts = extendAbLine(gl); + if (pts && pts.length >= 2) lineCmds(drawnPts(pts), false, cmds); } strokeCmdsSk(canvas, cmds, [SKP.extraGuideShadow, SKP.extraGuide]); } diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/settings-panels.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/settings-panels.js index f6f60c3fc..848a93bc8 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/js/settings-panels.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/js/settings-panels.js @@ -83,6 +83,13 @@ for (const t of vcPanel.querySelectorAll('.cfg-tab')) }); wireCfgControls(vcPanel); vcHitchSel.addEventListener('change', () => cfgSend('vehicle.hitchType', vcHitchSel.value)); +// The hitch field being typed in picks the hitch picture (the rear 3-point, the drawbar pin or the front 3-point). +let vcHitchKey = null; +for (const k of ['vehicle.threePointHitchLength', 'vehicle.hitchLength', 'vehicle.frontHitchLength']) { + const el = vcPanel.querySelector('.cfg-num[data-key="' + k + '"]'); + el.addEventListener('focus', () => { vcHitchKey = k; populateVehicleCfg(false); }); + el.addEventListener('blur', () => { if (vcHitchKey === k) vcHitchKey = null; populateVehicleCfg(false); }); +} // Heading fusion = GPS share (0–1), shown like AgOpenGPS: "GPS 30% · IMU 70%" (#112). const fusionLabel = w => tr('GPS {gps}% · IMU {imu}%', { gps: Math.round(w * 100), imu: 100 - Math.round(w * 100) }); vcFw.addEventListener('input', () => { document.getElementById('vc-hfw').textContent = fusionLabel(+vcFw.value); cfgSend('gps.headingFusionWeight', vcFw.value); }); @@ -104,12 +111,19 @@ function populateVehicleCfg(force) { if (n) { const p = '/icons/' + n + '.png'; if (!el.src.endsWith(p)) el.src = p; el.style.display = ''; } else el.style.display = 'none'; }; - setImg('vc-img-hitch', ['Hitch', '', 'HitchArticulated']); + // The hitch picture shows the field being typed in, else the hitch the tool hangs on (#547). + const hk = vcHitchKey || ({ 0: 'vehicle.frontHitchLength', 2: 'vehicle.hitchLength', 3: 'vehicle.hitchLength' }[cfgGet('tool.type')] || 'vehicle.threePointHitchLength'); + setImg('vc-img-hitch', hk === 'vehicle.hitchLength' ? ['Hitch', '', 'HitchArticulated'] + : hk === 'vehicle.frontHitchLength' ? ['VehicleHitch3ptFront', '', 'VehicleHitch3ptFront'] + : ['VehicleHitch3ptRear', '', 'VehicleHitch3ptRear']); setImg('vc-img-wheelbase', ['WheelbaseTractor', 'WheelbaseHarvester', 'WheelbaseArticulated']); setImg('vc-img-track', ['TrackWidthTractor', 'TrackWidthHarvester', 'TrackWidthArticulated']); setImg('vc-img-antside', ['AntennaTractorTop', 'AntennaHarvesterTop', 'AntennaArticulatedTop']); setImg('vc-img-antoffset', ['AntennaTractorOffset', 'AntennaHarvesterOffset', 'AntennaArticulatedOffset']); vcHitchSel.value = cfgGet('vehicle.hitchType'); + // The front 3-point hitch is asked for only with a front-mounted tool (type 0). + const front = cfgGet('tool.type') === 0; + for (const el of vcPanel.querySelectorAll('[data-vshow="front"]')) el.hidden = !front; const w = cfgGet('gps.headingFusionWeight') || 0; vcFw.value = w; document.getElementById('vc-hfw').textContent = fusionLabel(w); // Dual-only fields are live only in Dual GPS mode; reverse detection only in single. diff --git a/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js b/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js index 8cb69020e..6ef637112 100644 --- a/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js +++ b/Shared/AgOpenWeb.RemoteServer/wwwroot/transport.js @@ -370,7 +370,8 @@ window.RemoteTransport = { } case TYPE.CONFIG: { const vehicle = { - name: str(), type: i32(), hitchType: i32(), hitchLength: f64(), wheelbase: f64(), + name: str(), type: i32(), hitchType: i32(), hitchLength: f64(), + threePointHitchLength: f64(), frontHitchLength: f64(), wheelbase: f64(), trackWidth: f64(), antennaPivot: f64(), antennaHeight: f64(), antennaOffset: f64(), imuPivot: f64(), imuHeight: f64(), imuOffset: f64(), }; @@ -384,7 +385,7 @@ window.RemoteTransport = { const rdF64 = () => { const n = i32(), a = new Array(n); for (let k = 0; k < n; k++) a[k] = f64(); return a; }; const rdI32 = () => { const n = i32(), a = new Array(n); for (let k = 0; k < n; k++) a[k] = i32(); return a; }; const tool = { - type: i32(), hitchType: i32(), hitchLength: f64(), trailingHitchLength: f64(), + type: i32(), hitchType: i32(), workingPointFromHitch: f64(), trailingHitchLength: f64(), tankTrailingHitchLength: f64(), length: f64(), lookAheadOn: f64(), lookAheadOff: f64(), turnOffDelay: f64(), offset: f64(), overlap: f64(), trailingToolToPivotLength: f64(), isSectionsNotZones: !!u8(), numSections: i32(), defaultSectionWidth: f64(), diff --git a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.Helpers.cs b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.Helpers.cs index 53d0de59c..a163bf06d 100644 --- a/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.Helpers.cs +++ b/Shared/AgOpenWeb.RemoteWiring/RemoteServerWiring.Helpers.cs @@ -118,6 +118,8 @@ bool IDX(out int i, out string rest) // "i,rest" — indexed array writes return; case "vehicle.hitchType": if (I(out var ht)) veh.HitchType = ht; return; case "vehicle.hitchLength": if (D(out var d1)) veh.HitchLength = d1; return; + case "vehicle.threePointHitchLength": if (D(out var d1b)) veh.ThreePointHitchLength = d1b; return; + case "vehicle.frontHitchLength": if (D(out var d1c)) veh.FrontHitchLength = d1c; return; case "vehicle.wheelbase": if (D(out var d2)) veh.Wheelbase = d2; return; case "vehicle.trackWidth": if (D(out var d3)) veh.TrackWidth = d3; return; case "vehicle.antennaPivot": if (D(out var d4)) veh.AntennaPivot = d4; return; @@ -153,7 +155,7 @@ bool IDX(out int i, out string rest) // "i,rest" — indexed array writes case "tool.type": // front/rear/tbt/trailing; a harvester only takes front (#111) tool.SetToolType(veh.Type == AgOpenWeb.Models.VehicleType.Harvester ? "front" : val); return; case "tool.hitchType": if (I(out var th)) tool.HitchType = th; return; - case "tool.hitchLength": if (D(out var t1)) tool.HitchLength = t1; return; + case "tool.workingPointFromHitch": if (D(out var t1)) tool.WorkingPointFromHitch = t1; return; case "tool.trailingHitchLength": if (D(out var t2)) tool.TrailingHitchLength = t2; return; case "tool.tankTrailingHitchLength": if (D(out var t3)) tool.TankTrailingHitchLength = t3; return; case "tool.length": if (D(out var t4)) tool.Length = t4; return; diff --git a/Shared/AgOpenWeb.Services/ConfigurationService.cs b/Shared/AgOpenWeb.Services/ConfigurationService.cs index 11cbd241e..e36fede73 100644 --- a/Shared/AgOpenWeb.Services/ConfigurationService.cs +++ b/Shared/AgOpenWeb.Services/ConfigurationService.cs @@ -108,10 +108,13 @@ private static string FormatVehicle(ConfigurationStore store, bool isMetric) private static string FormatTool(ConfigurationStore store, bool isMetric) { var t = store.Tool; - string attach = t.IsToolFrontFixed ? "Front fixed" - : t.IsToolRearFixed ? "Rear fixed" - : t.IsToolTrailing ? "Trailing" - : "—"; + string attach = AgOpenWeb.Models.Tool.RigGeometry.Mount(t) switch + { + AgOpenWeb.Models.Tool.ToolMount.FrontFixed => "Front fixed", + AgOpenWeb.Models.Tool.ToolMount.RearFixed => "Rear fixed", + AgOpenWeb.Models.Tool.ToolMount.TBT => "TBT", + _ => "Trailing", + }; return // The sections' total, as guidance and coverage use; Tool.Width is a stored // default the section editor never updates (#345). @@ -154,6 +157,7 @@ public bool LoadProfiles(string vehicleName, string toolName) // Capture corruption status up-front (no mutation, no quarantine) so we // can record it for the startup recovery prompt after the load. var probe = ProbeProfiles(vehicleName, toolName); + Store.ProfileMigrationNote = null; // profileService.Load transparently recovers from the .bak // last-known-good copy when the primary v2 file is damaged. @@ -169,18 +173,11 @@ public bool LoadProfiles(string vehicleName, string toolName) // CreateDefaultProfile path) populated it. toolProfileService.Load(toolName, Store); - // One-way hitch migration (vehicle/tool config split): pre-split profiles stored - // the trailing/TBT tractor hitch-pin distance under Tool.HitchLength. It is now a - // vehicle property (Vehicle.HitchLength = axle -> hitch pin). When the vehicle file - // predates the split it loads as NaN (sentinel); seed it from the legacy tool value - // so existing trailing/TBT setups keep working, then it persists under the vehicle on - // next save. Rigid setups keep their own Tool.HitchLength regardless; a stray vehicle - // value is harmless because rigid geometry never reads it. Must run after BOTH files - // load (vehicle loads first), so the legacy Tool.HitchLength is available here. - if (double.IsNaN(Store.Vehicle.HitchLength)) - { - Store.Vehicle.HitchLength = Store.Tool.HitchLength; - } + // A vehicle file from before the hitch moved to the vehicle loads with no hitch (NaN); a + // format-2 tool loaded with it filled it in (HitchMigration). With no tool file to take it + // from, the default. + if (double.IsNaN(Store.Vehicle.HitchLength)) Store.Vehicle.HitchLength = 1.8; + if (double.IsNaN(Store.Vehicle.ThreePointHitchLength)) Store.Vehicle.ThreePointHitchLength = Store.Vehicle.HitchLength; LoadAutoSteerConfig(vehicleName); @@ -202,6 +199,12 @@ public bool LoadProfiles(string vehicleName, string toolName) { SaveProfiles(vehicleName, toolName); } + // A tool converted to the hitch on the vehicle (format 3, HitchMigration) is saved at once + // with its vehicle: a tool saved later without the vehicle's new hitch would move. + else if (Store.ProfileMigrationNote != null) + { + SaveProfiles(vehicleName, toolName); + } // Persist the active pair so the next startup restores the same // combo instead of falling through to LoadProfile(name) and pairing diff --git a/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs b/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs index a26524eb7..8e1830799 100644 --- a/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs +++ b/Shared/AgOpenWeb.Services/Coverage/CoverageMapService.cs @@ -1713,6 +1713,7 @@ public void ResetUserArea() private bool _displayFullSave = true; private string? _savedDirectory; // job folder the tiles on disk match (null: unknown) + private string? _newerFormatDirectory; // a newer build's coverage folder: never written (CoverageTileStore.IsNewerFormat) private string? _legacyFilesDirectory; // job imported from coverage_*.bin or Sections.txt: delete them after its first tiled save // Serialises saves: the autosave can still be running when the field-close save starts, @@ -1755,7 +1756,11 @@ public void SaveToFile(string fieldDirectory) lock (_saveLock) { string dir = Path.GetFullPath(fieldDirectory); + if (dir == _newerFormatDirectory) + return; var store = new CoverageTileStore(dir); + if (store.IsNewerFormat) + return; // a newer build's coverage: this build would replace its manifest and tiles TileSave save; lock (_coverageLock) { @@ -1953,6 +1958,20 @@ public void LoadFromFile(string fieldDirectory) var store = new CoverageTileStore(dir); bool hasDetectionBits, hasSectionDisplay, fromTiles = false, fromOldFiles = false; + // A newer build's coverage: shown as none and never saved, so it stays as that build left it. + _newerFormatDirectory = null; + if (store.IsNewerFormat) + { + ClearAll(); + lock (_coverageLock) + { + _savedDirectory = null; + _legacyFilesDirectory = null; + } + _newerFormatDirectory = dir; + return; + } + var manifest = store.ManifestExists || Directory.Exists(store.DetectionDir) ? store.ReadManifest() ?? RecoverManifest(store) : null; diff --git a/Shared/AgOpenWeb.Services/Coverage/CoverageTileStore.cs b/Shared/AgOpenWeb.Services/Coverage/CoverageTileStore.cs index faa84aa84..75476137d 100644 --- a/Shared/AgOpenWeb.Services/Coverage/CoverageTileStore.cs +++ b/Shared/AgOpenWeb.Services/Coverage/CoverageTileStore.cs @@ -71,9 +71,36 @@ public static string TileFileName(int tx, int ty) => // ---------- manifest ---------- + /// + /// The manifest version this build writes. A coverage folder in a newer version is never + /// read or written by this build (): reading it would set the + /// manifest aside as damaged, and the next save would replace it and delete the tiles it + /// doesn't know. + /// + public const int CurrentSchemaVersion = 1; + + /// The manifest (or its backup) was written by a newer build. + public bool IsNewerFormat => + ManifestVersionOf(ManifestPath) > CurrentSchemaVersion + || ManifestVersionOf(ManifestPath + AtomicJsonFile.BackupSuffix) > CurrentSchemaVersion; + + private static int ManifestVersionOf(string path) + { + if (!File.Exists(path)) return CurrentSchemaVersion; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty(nameof(Manifest.SchemaVersion), out var v) && v.TryGetInt32(out int n)) + return n; + } + catch (Exception ex) when (ex is JsonException or IOException) { } + return CurrentSchemaVersion; + } + public sealed class Manifest { - public int SchemaVersion { get; set; } = 1; + public int SchemaVersion { get; set; } = CurrentSchemaVersion; public double DisplayCellSize { get; set; } public double TotalWorkedArea { get; set; } public double MinE { get; set; } @@ -87,8 +114,10 @@ public void WriteManifest(Manifest manifest) => public Manifest? ReadManifest() { + if (IsNewerFormat) + return null; // never set a newer build's manifest aside as damaged var result = AtomicJsonFile.Read(ManifestPath, JsonOptions, - m => m.SchemaVersion == 1 && m.DisplayCellSize > 0 && m.MaxE > m.MinE && m.MaxN > m.MinN); + m => m.SchemaVersion == CurrentSchemaVersion && m.DisplayCellSize > 0 && m.MaxE > m.MinE && m.MaxN > m.MinN); return result.Loaded ? result.Value : null; } diff --git a/Shared/AgOpenWeb.Services/DebugDumpService.cs b/Shared/AgOpenWeb.Services/DebugDumpService.cs index 197e566a7..01e6a4c1f 100644 --- a/Shared/AgOpenWeb.Services/DebugDumpService.cs +++ b/Shared/AgOpenWeb.Services/DebugDumpService.cs @@ -414,14 +414,20 @@ private static string BuildConfigSnapshot(ConfigurationStore store) store.Vehicle.AntennaHeight, store.Vehicle.AntennaPivot, store.Vehicle.AntennaOffset, - store.Vehicle.MaxSteerAngle + store.Vehicle.MaxSteerAngle, + store.Vehicle.HitchLength, + store.Vehicle.ThreePointHitchLength, + store.Vehicle.FrontHitchLength }, Tool = new { store.Tool.Width, store.Tool.Overlap, store.Tool.Offset, - store.Tool.HitchLength, + // The working point from the tractor's hitch (format 3, #547), and from the rear axle + // as format 2 had it (what older dumps carry as HitchLength). + store.Tool.WorkingPointFromHitch, + HitchLength = AgOpenWeb.Models.Tool.RigGeometry.AxleToWorkingPoint(store.Vehicle, store.Tool), store.Tool.TrailingHitchLength, // Where the working line is (#679: a placement report needs these, and they were missing). store.Tool.TrailingToolToPivotLength, diff --git a/Shared/AgOpenWeb.Services/Fields/JobJsonService.cs b/Shared/AgOpenWeb.Services/Fields/JobJsonService.cs index a864cf875..7b315d26e 100644 --- a/Shared/AgOpenWeb.Services/Fields/JobJsonService.cs +++ b/Shared/AgOpenWeb.Services/Fields/JobJsonService.cs @@ -23,6 +23,12 @@ public static class JobJsonService private const string JobsFolder = "jobs"; private const string JobFileName = "job.json"; + /// + /// The job.json version this build writes. A new member doesn't change it (a reader ignores + /// it, a writer keeps it: ); a change of meaning raises it. + /// A job in a newer version, or whose coverage is (), + /// is listed but never written: . + /// public const int CurrentSchemaVersion = 1; private static readonly JsonSerializerOptions SerializerOptions = new() @@ -79,9 +85,38 @@ public static void Save(Job job, string fieldDirectory) var dir = JobDirectory(fieldDirectory, job.TaskName); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + var path = Path.Combine(dir, JobFileName); + if (SchemaVersionOf(path) > CurrentSchemaVersion) + return; // a newer build's job: read, never written + var dto = JobDto.FromJob(job); var json = JsonSerializer.Serialize(dto, SerializerOptions); - File.WriteAllText(Path.Combine(dir, JobFileName), json); + File.WriteAllText(path, json); + } + + /// + /// The job was saved by a newer build, its job.json or its coverage, in a format this one + /// doesn't fully understand: it is listed, but its coverage isn't loaded and nothing is + /// written to it, so this build can't overwrite what it doesn't understand. + /// + public static bool IsNewerFormat(string fieldDirectory, string taskName) => + SchemaVersionOf(JobFilePath(fieldDirectory, taskName)) > CurrentSchemaVersion + || new Coverage.CoverageTileStore(JobDirectory(fieldDirectory, taskName)).IsNewerFormat; + + /// A job.json's schemaVersion; 1 when it has none, or there is no readable file. + private static int SchemaVersionOf(string path) + { + if (!File.Exists(path)) return CurrentSchemaVersion; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + foreach (var p in doc.RootElement.EnumerateObject()) + if (string.Equals(p.Name, "schemaVersion", StringComparison.OrdinalIgnoreCase) + && p.Value.TryGetInt32(out int v)) + return Math.Max(1, v); + } + catch (Exception ex) when (ex is JsonException or IOException or InvalidOperationException) { } + return CurrentSchemaVersion; } /// @@ -139,10 +174,17 @@ private sealed class JobDto public DateTime? StartedAt { get; set; } public DateTime? EndedAt { get; set; } public DateTime? LastOpenedAt { get; set; } - public JobStatus? Status { get; set; } + // Read leniently: a status a newer build added must not make the job unreadable (it would + // drop out of the list). Such a job is never written, so the stand-in is never saved. + public JsonElement? Status { get; set; } public double DistanceTraveledMeters { get; set; } public double AreaWorkedHectares { get; set; } public int UTurnCount { get; set; } + public JobRig? Rig { get; set; } + + /// Members this build doesn't read, kept as they were. + [JsonExtensionData] + public Dictionary? Other { get; set; } public static JobDto FromJob(Job j) => new() { @@ -155,10 +197,12 @@ private sealed class JobDto StartedAt = j.StartedAt, EndedAt = j.EndedAt, LastOpenedAt = j.LastOpenedAt, - Status = j.Status, + Status = JsonSerializer.SerializeToElement(j.Status, SerializerOptions), DistanceTraveledMeters = j.DistanceTraveledMeters, AreaWorkedHectares = j.AreaWorkedHectares, - UTurnCount = j.UTurnCount + UTurnCount = j.UTurnCount, + Rig = j.Rig, + Other = j.UnknownProperties?.ToDictionary(p => p.Key, p => p.Value), }; public Job ToJob(string fieldName, string taskName) => new() @@ -171,10 +215,21 @@ private sealed class JobDto StartedAt = StartedAt ?? DateTime.Now, EndedAt = EndedAt, LastOpenedAt = LastOpenedAt ?? DateTime.Now, - Status = Status ?? JobStatus.InProgress, + Status = ReadStatus(Status), DistanceTraveledMeters = DistanceTraveledMeters, AreaWorkedHectares = AreaWorkedHectares, - UTurnCount = UTurnCount + UTurnCount = UTurnCount, + Rig = Rig, + UnknownProperties = Other, }; + + private static JobStatus ReadStatus(JsonElement? e) + { + if (e is { ValueKind: JsonValueKind.String } s && Enum.TryParse(s.GetString(), true, out var named)) + return named; + if (e is { ValueKind: JsonValueKind.Number } n && n.TryGetInt32(out int i) && Enum.IsDefined(typeof(JobStatus), i)) + return (JobStatus)i; + return JobStatus.InProgress; + } } } diff --git a/Shared/AgOpenWeb.Services/GeoJson/GeoJsonFieldService.cs b/Shared/AgOpenWeb.Services/GeoJson/GeoJsonFieldService.cs index e9a4f4323..b817cad85 100644 --- a/Shared/AgOpenWeb.Services/GeoJson/GeoJsonFieldService.cs +++ b/Shared/AgOpenWeb.Services/GeoJson/GeoJsonFieldService.cs @@ -25,6 +25,48 @@ public partial class GeoJsonFieldService { private const string FileName = "field.geojson"; + /// + /// The version of the format this build writes (the metadata feature's "formatVersion"; a + /// file without one is version 1). A new role or a new property doesn't change it: a reader + /// ignores what it doesn't know and a writer keeps it (the parts' UnknownProperties). Raise + /// it only when a change alters what something already in the file means. A build that + /// finds a higher version reads the field but writes nothing to it (), + /// so it can't overwrite what it doesn't understand. + /// + public const int CurrentFormatVersion = 1; + + // The properties each role's reader reads (and its builder writes). Every other property of + // a feature is kept in the part's UnknownProperties and written back. + private static readonly HashSet MetadataKeys = new() + { + FieldPropertyKeys.Role, FieldPropertyKeys.FormatVersion, FieldPropertyKeys.Name, + FieldPropertyKeys.OriginLatitude, FieldPropertyKeys.OriginLongitude, FieldPropertyKeys.Convergence, + FieldPropertyKeys.AreaHectares, FieldPropertyKeys.CreatedDate, FieldPropertyKeys.LastModifiedDate, + }; + private static readonly HashSet BoundaryKeys = new() + { + FieldPropertyKeys.Role, FieldPropertyKeys.IsDriveThrough, FieldPropertyKeys.IsHard, FieldPropertyKeys.AreaHectares, + }; + private static readonly HashSet TrackKeys = new() + { + FieldPropertyKeys.Role, FieldPropertyKeys.Name, FieldPropertyKeys.TrackType, FieldPropertyKeys.IsClosed, + FieldPropertyKeys.NoPassOffset, FieldPropertyKeys.NudgeDistance, FieldPropertyKeys.IsVisible, + }; + private static readonly HashSet FlagKeys = new() + { + FieldPropertyKeys.Role, FieldPropertyKeys.Name, FieldPropertyKeys.Color, FieldPropertyKeys.Id, FieldPropertyKeys.Notes, + }; + private static readonly HashSet HeadlandLineKeys = new() + { + FieldPropertyKeys.Role, FieldPropertyKeys.Name, FieldPropertyKeys.MoveDistance, FieldPropertyKeys.Mode, + FieldPropertyKeys.APointIndex, + }; + private static readonly HashSet BackgroundKeys = new() + { + FieldPropertyKeys.Role, FieldPropertyKeys.Image, FieldPropertyKeys.MercatorMinX, FieldPropertyKeys.MercatorMaxX, + FieldPropertyKeys.MercatorMinY, FieldPropertyKeys.MercatorMaxY, + }; + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, @@ -100,13 +142,15 @@ public static void Save(Models.Field field, IReadOnlyList? t try { existing = ReadCollection(path); } catch (Exception ex) when (ex is InvalidDataException or JsonException) { } } + if (existing != null && FormatVersionOf(existing) > CurrentFormatVersion) + return; // a newer build's field: read, never written (CurrentFormatVersion) if (existing != null) features.AddRange(existing.Features.Where(f => { var role = GetStringProp(f, FieldPropertyKeys.Role) ?? ""; return !FieldRoles.Contains(role) && !(tracks != null && role == FeatureRoles.Track); })); - Write(path, new GeoJsonFeatureCollection { Features = features }); + Write(path, new GeoJsonFeatureCollection { Features = features, Other = existing?.Other }); } } @@ -151,6 +195,8 @@ private static void ReplaceRole(string fieldDirectory, string role, Func CurrentFormatVersion) + return; // a newer build's field: read, never written (CurrentFormatVersion) var geo = ProjectionOf(fc); fc.Features.RemoveAll(f => GetStringProp(f, FieldPropertyKeys.Role) == role); fc.Features.AddRange(build(geo)); @@ -178,6 +224,47 @@ private static GeoJsonFeatureCollection ReadCollection(string path) ?? throw new InvalidDataException("Failed to deserialize GeoJSON"); } + /// The format version of the field's file (1 when it has none, or no file). + public static int FormatVersion(string fieldDirectory) + { + var path = Path.Combine(fieldDirectory, FileName); + if (!File.Exists(path)) return CurrentFormatVersion; + try { return FormatVersionOf(ReadCollection(path)); } + catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException) { return CurrentFormatVersion; } + } + + /// + /// The field was written by a newer build in a format this one doesn't fully understand: it + /// is read and can be worked, but nothing is written to its field.geojson. + /// + public static bool IsNewerFormat(string fieldDirectory) => FormatVersion(fieldDirectory) > CurrentFormatVersion; + + private static int FormatVersionOf(GeoJsonFeatureCollection fc) + { + var meta = fc.Features.FirstOrDefault(f => GetStringProp(f, FieldPropertyKeys.Role) == FeatureRoles.Metadata); + if (meta == null || !HasProp(meta, FieldPropertyKeys.FormatVersion)) return 1; + return Math.Max(1, GetIntProp(meta, FieldPropertyKeys.FormatVersion)); + } + + /// A feature's properties outside , or null if none. + private static IReadOnlyDictionary? Unknown(GeoJsonFeature f, HashSet known) + { + Dictionary? extra = null; + foreach (var (key, value) in f.Properties) + if (!known.Contains(key)) + (extra ??= new Dictionary())[key] = value; + return extra; + } + + /// Add a part's unknown properties back; a property the builder wrote wins. + private static Dictionary WithUnknown(Dictionary props, IReadOnlyDictionary? unknown) + { + if (unknown != null) + foreach (var (key, value) in unknown) + props.TryAdd(key, value); + return props; + } + private static GeoJsonFeature MetadataOf(GeoJsonFeatureCollection fc) => fc.Features.FirstOrDefault(f => GetStringProp(f, FieldPropertyKeys.Role) == FeatureRoles.Metadata) ?? throw new InvalidDataException("GeoJSON missing metadata feature"); @@ -220,6 +307,7 @@ public static (Models.Field field, List tracks) Load(string DirectoryPath = fieldDirectory, Origin = new Position { Latitude = originLat, Longitude = originLon }, Convergence = GetDoubleProp(metaFeature, FieldPropertyKeys.Convergence), + UnknownProperties = Unknown(metaFeature, MetadataKeys), }; var nameProp = GetStringProp(metaFeature, FieldPropertyKeys.Name); @@ -337,9 +425,10 @@ private static GeoJsonFeature BuildMetadataFeature(Models.Field field) Type = GeoJsonTypes.Point, Coordinates = new[] { field.Origin.Longitude, field.Origin.Latitude } }, - Properties = new Dictionary + Properties = WithUnknown(new Dictionary { [FieldPropertyKeys.Role] = FeatureRoles.Metadata, + [FieldPropertyKeys.FormatVersion] = CurrentFormatVersion, [FieldPropertyKeys.Name] = field.Name, [FieldPropertyKeys.OriginLatitude] = field.Origin.Latitude, [FieldPropertyKeys.OriginLongitude] = field.Origin.Longitude, @@ -347,7 +436,7 @@ private static GeoJsonFeature BuildMetadataFeature(Models.Field field) [FieldPropertyKeys.AreaHectares] = field.TotalArea, [FieldPropertyKeys.CreatedDate] = field.CreatedDate.ToString("O"), [FieldPropertyKeys.LastModifiedDate] = field.LastModifiedDate.ToString("O"), - } + }, field.UnknownProperties) }; } @@ -361,13 +450,13 @@ private static GeoJsonFeature BuildBoundaryFeature(Projection geo, BoundaryPolyg Type = GeoJsonTypes.Polygon, Coordinates = new[] { ring } }, - Properties = new Dictionary + Properties = WithUnknown(new Dictionary { [FieldPropertyKeys.Role] = role, [FieldPropertyKeys.IsDriveThrough] = polygon.IsDriveThrough, [FieldPropertyKeys.IsHard] = polygon.IsHard, [FieldPropertyKeys.AreaHectares] = polygon.AreaHectares, - } + }, polygon.UnknownProperties) }; if (role == FeatureRoles.InnerBoundary) { @@ -407,7 +496,7 @@ private static GeoJsonFeature BuildTrackFeature(Projection geo, Models.Track.Tra Type = GeoJsonTypes.LineString, Coordinates = coords.Select(c => (object)c).ToArray() }, - Properties = new Dictionary + Properties = WithUnknown(new Dictionary { [FieldPropertyKeys.Role] = FeatureRoles.Track, [FieldPropertyKeys.Name] = track.Name, @@ -416,7 +505,7 @@ private static GeoJsonFeature BuildTrackFeature(Projection geo, Models.Track.Tra [FieldPropertyKeys.NoPassOffset] = track.NoPassOffset, [FieldPropertyKeys.NudgeDistance] = track.NudgeDistance, [FieldPropertyKeys.IsVisible] = track.IsVisible, - } + }, track.UnknownProperties) }; } @@ -443,7 +532,7 @@ private static GeoJsonFeature BuildBackgroundFeature(FieldBackground bg) Type = GeoJsonTypes.Polygon, Coordinates = new[] { new object[] { new[] { w, n }, new[] { e, n }, new[] { e, s }, new[] { w, s }, new[] { w, n } } }, }, - Properties = props, + Properties = WithUnknown(props, bg.UnknownProperties), }; } @@ -483,7 +572,7 @@ private static GeoJsonFeature BuildFlagFeature(Projection geo, Flag flag) return new GeoJsonFeature { Geometry = new GeoJsonGeometry { Type = GeoJsonTypes.Point, Coordinates = new[] { lon, lat } }, - Properties = props, + Properties = WithUnknown(props, flag.UnknownProperties), }; } @@ -496,14 +585,14 @@ private static GeoJsonFeature BuildHeadlandPathFeature(Projection geo, HeadlandP Type = GeoJsonTypes.LineString, Coordinates = geo.ToGeoJsonCoordinates(path.TrackPoints).Select(c => (object)c).ToArray(), }, - Properties = new Dictionary + Properties = WithUnknown(new Dictionary { [FieldPropertyKeys.Role] = FeatureRoles.HeadlandLine, [FieldPropertyKeys.Name] = path.Name, [FieldPropertyKeys.MoveDistance] = path.MoveDistance, [FieldPropertyKeys.Mode] = path.Mode, [FieldPropertyKeys.APointIndex] = path.APointIndex, - } + }, path.UnknownProperties) }; } @@ -538,6 +627,7 @@ private static GeoJsonFeature BuildHeadlandPathFeature(Projection geo, HeadlandP } polygon.UpdateBounds(); + polygon.UnknownProperties = Unknown(feature, BoundaryKeys); return polygon; } @@ -567,6 +657,7 @@ private static GeoJsonFeature BuildHeadlandPathFeature(Projection geo, HeadlandP NoPassOffset = GetBoolProp(feature, FieldPropertyKeys.NoPassOffset), NudgeDistance = GetDoubleProp(feature, FieldPropertyKeys.NudgeDistance), IsVisible = GetBoolPropDefault(feature, FieldPropertyKeys.IsVisible, true), + UnknownProperties = Unknown(feature, TrackKeys), }; } @@ -585,6 +676,7 @@ private static GeoJsonFeature BuildHeadlandPathFeature(Projection geo, HeadlandP GetStringProp(feature, FieldPropertyKeys.Name) is { Length: > 0 } name ? name : $"Flag {id}"); if (GetStringProp(feature, FieldPropertyKeys.Notes) is { Length: > 0 } notes) flag.Notes = notes; + flag.UnknownProperties = Unknown(feature, FlagKeys); return flag; } @@ -600,6 +692,7 @@ private static GeoJsonFeature BuildHeadlandPathFeature(Projection geo, HeadlandP MoveDistance = GetDoubleProp(feature, FieldPropertyKeys.MoveDistance), Mode = GetIntProp(feature, FieldPropertyKeys.Mode), APointIndex = GetIntProp(feature, FieldPropertyKeys.APointIndex), + UnknownProperties = Unknown(feature, HeadlandLineKeys), }; } @@ -616,7 +709,7 @@ private static GeoJsonFeature BuildHeadlandPathFeature(Projection geo, HeadlandP return new FieldBackground(image, coords.Max(c => c[1]), coords.Min(c => c[0]), coords.Min(c => c[1]), coords.Max(c => c[0]), - mercator); + mercator) { UnknownProperties = Unknown(feature, BackgroundKeys) }; } // --------------------------------------------------------------- diff --git a/Shared/AgOpenWeb.Services/Interfaces/IJobService.cs b/Shared/AgOpenWeb.Services/Interfaces/IJobService.cs index a94e083b6..836e4465c 100644 --- a/Shared/AgOpenWeb.Services/Interfaces/IJobService.cs +++ b/Shared/AgOpenWeb.Services/Interfaces/IJobService.cs @@ -102,6 +102,12 @@ public interface IJobService /// Job? ActiveJob { get; } + /// + /// Keep the machine the active job is worked with (#547): the job's copy is replaced and its + /// job.json saved when it differs. Nothing without an active job. + /// + void RecordRig(JobRig rig); + /// /// Fired whenever changes (including to null). /// diff --git a/Shared/AgOpenWeb.Services/JobService.cs b/Shared/AgOpenWeb.Services/JobService.cs index 9fb874ae6..b701b126e 100644 --- a/Shared/AgOpenWeb.Services/JobService.cs +++ b/Shared/AgOpenWeb.Services/JobService.cs @@ -196,6 +196,15 @@ public bool DeleteJob(string fieldName, string taskName) return true; } + public void RecordRig(JobRig rig) + { + if (ActiveJob == null || rig.SameAs(ActiveJob.Rig)) return; + var fieldDir = ResolveFieldDir(ActiveJob.FieldName); + if (fieldDir == null) return; + ActiveJob.Rig = rig; + JobJsonService.Save(ActiveJob, fieldDir); // not to a newer build's job (Save leaves it) + } + public void SuspendCurrentJob() { if (ActiveJob == null) return; diff --git a/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs b/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs index 0b32293cc..66943b7cb 100644 --- a/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs +++ b/Shared/AgOpenWeb.Services/Pipeline/GpsPipelineService.cs @@ -2078,10 +2078,13 @@ private static GuidanceWorkingState WithLineLabel(GuidanceWorkingState g, int? l { int pass = landingPass ?? g.HowManyPathsAway; bool sameWay = landingPass.HasValue ? !g.IsHeadingSameWay : g.IsHeadingSameWay; - g.CurrentLineLabel = (sameWay ? "↑" : "↓") + (pass >= 0 ? $"{pass + 1}R" : $"{-pass}L"); + g.CurrentLineLabel = (sameWay ? "↑" : "↓") + PassName(pass); return g; } + /// The pass as its label names it: pass 0 is 1R, pass −1 is 1L. + private static string PassName(int pass) => pass >= 0 ? $"{pass + 1}R" : $"{-pass}L"; + private static GuidanceSnapshot BuildGuidanceSnapshot( GuidanceWorkingState src, Models.Track.Track? displayTrack, @@ -2176,7 +2179,8 @@ private double OpeningTurnRoom() Models.Track.Track steered) { var config = _configStore; - double reach = Math.Abs(config.Tool.HitchLength); + // The working point ahead of the rear axle: the vehicle's front hitch plus the tool's distance from it (#547). + double reach = Math.Abs(Models.Tool.RigGeometry.AttachX(config.Vehicle, config.Tool)); double minRadius = config.Vehicle.MinTurningRadius; double passRadius = config.Guidance.UTurnRadius; // the passes' bends are opened to it var key = (track, track.Points, distAway, bent, steered.Points.Count, steered.IsClosed, reach, minRadius, passRadius); @@ -2304,7 +2308,7 @@ private double OpeningTurnRoom() // A front-mounted implement runs outside the pivot's line round a bend: the pivot steers // inside it, so the implement runs on the pass (FrontToolLine). The same points, moved. // Fork only. - if (config.Tool.IsToolFrontFixed && currentTrack.Points.Count > 2 + if (Models.Tool.RigGeometry.Mount(config.Tool) == Models.Tool.ToolMount.FrontFixed && currentTrack.Points.Count > 2 && FrontToolSteered(track, distAway, bent, currentTrack) is { } frontLine) currentTrack = new Models.Track.Track { @@ -3245,9 +3249,9 @@ private SideStepPlan PlanSideStep(ConfigurationStore config, Models.Track.Track IReadOnlyList line = sameWay ? pass : Reversed(pass); var tool = config.Tool; - double along = tool.IsToolFrontFixed ? Math.Abs(tool.HitchLength) - : tool.IsToolRearFixed ? -Math.Abs(tool.HitchLength) - : -Math.Abs(config.Vehicle.HitchLength); // a trailed implement at the hitch + // Along the tractor from the rear axle (+ ahead): a fixed implement's working point, a trailed + // implement at the drawbar pin (RigGeometry, #547). + double along = Models.Tool.RigGeometry.AttachX(config.Vehicle, tool); double reach = config.ActualToolWidth / 2 + Math.Abs(tool.Offset); // as the fence guard var obstacles = boundary.InnerBoundaries.Where(o => o is { IsValid: true, IsDriveThrough: false }).ToList(); // On an open pass with U-turns the turn is planned only after the S: it must end in the @@ -3284,9 +3288,6 @@ private static bool InHeadland(Boundary boundary, List headlandLine, doubl boundary.OuterBoundary is { IsValid: true } outer && outer.IsPointInside(e, n) && !GeometryMath.IsPointInPolygon(headlandLine, e, n); - /// A pass as the pass label names it, without the arrow: pass 0 is 1R, pass −1 is 1L. - private static string PassName(int pass) => pass >= 0 ? $"{pass + 1}R" : $"{-pass}L"; - private string? TakeLineEntryNotice() { var notice = _lineEntryNotice; diff --git a/Shared/AgOpenWeb.Services/Profile/AgOpenGpsProfileImport.cs b/Shared/AgOpenWeb.Services/Profile/AgOpenGpsProfileImport.cs index 30614320d..a58ba43f3 100644 --- a/Shared/AgOpenWeb.Services/Profile/AgOpenGpsProfileImport.cs +++ b/Shared/AgOpenWeb.Services/Profile/AgOpenGpsProfileImport.cs @@ -69,17 +69,20 @@ public static AgOpenGpsImport Run(string dataRoot, string vehiclesDirectory, ITo if (toolFiles.Count > 0 && TryRead(toolFiles[0]) is { } inUse) foreach (var key in GuidanceKeys) if (inUse.TryGetValue(key, out var value)) fromImplements[key] = value; - // The tractor's hitch pin: AgOpenGPS keeps it as a trailed implement's hitch length, - // AgOpenWeb with the tractor. Taken from the trailed implement saved last. + // The tractor's hitch points: AgOpenGPS keeps them as its implements' hitch lengths, + // AgOpenWeb with the tractor (#547). The drawbar pin from the trailed implement saved last; + // the rear 3-point and the front hitch from the rear- and front-mounted implements saved + // last, each fixed implement then its AgOpenGPS distance less that hitch (AgOpenGpsSettings). + var hitches = new Dictionary(); foreach (var file in toolFiles) { if (TryRead(file) is not { } tool || !tool.TryGetValue(HitchKey, out var hitch)) continue; - if (IsTrue(tool, "setTool_isToolTrailing") || IsTrue(tool, "setTool_isToolTBT")) - { - fromImplements[HitchKey] = hitch; - break; - } + string key = IsTrue(tool, "setTool_isToolTrailing") || IsTrue(tool, "setTool_isToolTBT") ? HitchKey + : IsTrue(tool, "setTool_isToolFront") ? AgOpenGpsSettings.FrontHitchKey + : AgOpenGpsSettings.ThreePointHitchKey; + hitches.TryAdd(key, hitch); } + foreach (var (k, v) in hitches) fromImplements[k] = v; var vehicles = new List(); var toolNames = new List(); @@ -121,7 +124,12 @@ public static AgOpenGpsImport Run(string dataRoot, string vehiclesDirectory, ITo try { var store = new ConfigurationStore(); - AgOpenGpsSettings.ApplyToStore(Merge(environment, own), name, file, store); + // The tractor's 3-point and front hitch go with each implement, so a fixed one's + // distance is from them (the drawbar is the implement's own hitch length here). + var tractorHitches = new Dictionary(); + foreach (var key in new[] { AgOpenGpsSettings.ThreePointHitchKey, AgOpenGpsSettings.FrontHitchKey }) + if (hitches.TryGetValue(key, out var h)) tractorHitches[key] = h; + AgOpenGpsSettings.ApplyToStore(Merge(environment, tractorHitches, own), name, file, store); tools.Save(name, store); } catch (Exception) diff --git a/Shared/AgOpenWeb.Services/Profile/AgOpenGpsSettings.cs b/Shared/AgOpenWeb.Services/Profile/AgOpenGpsSettings.cs index e1f688ab5..662318f68 100644 --- a/Shared/AgOpenWeb.Services/Profile/AgOpenGpsSettings.cs +++ b/Shared/AgOpenWeb.Services/Profile/AgOpenGpsSettings.cs @@ -43,6 +43,15 @@ public static Dictionary Parse(XDocument doc) /// active vehicle profile's path (an AgOpenGPS .xml: then /// keeps the AutoSteer settings read here instead of looking for its own AutoSteer file). /// + /// + /// Not AgOpenGPS's: the tractor's rear 3-point and front hitch, from the rear- and front-mounted + /// implements saved last (), so a fixed implement's distance + /// from its hitch keeps it where AgOpenGPS has it (#547). + /// + public const string ThreePointHitchKey = "agopenweb_threePointHitchLength"; + /// + public const string FrontHitchKey = "agopenweb_frontHitchLength"; + public static void ApplyToStore(IReadOnlyDictionary settings, string profileName, string filePath, ConfigurationStore store) { // Vehicle config @@ -84,14 +93,17 @@ public static void ApplyToStore(IReadOnlyDictionary settings, st store.Tool.Width = GetDouble(settings, "setVehicle_toolWidth", 6.0); store.Tool.Overlap = GetDouble(settings, "setVehicle_toolOverlap", 0.0); store.Tool.Offset = GetDouble(settings, "setVehicle_toolOffset", 0.0); - // AOG's single setVehicle_hitchLength is the tractor hitch pin (now a vehicle - // property, used by trailing/TBT) AND doubles as the rigid working-center distance. - // Import into both so the value is present whichever tool type the profile uses. - // AgOpenGPS writes it negative behind the tractor; AgOpenWeb keeps the distance (the - // tool's direction gives the side), and Tool.HitchLength took a negative one as 0. + // AgOpenGPS's one setVehicle_hitchLength, kept per implement: a trailed implement's is the + // tractor's drawbar pin, a fixed implement's is its working point from the rear axle (CTool: + // the working point is at the hitch). AgOpenWeb keeps the tractor's hitch points with the + // tractor and a fixed tool's distance from its hitch (#547). The import names the tractor's + // 3-point and front hitch (ThreePointHitchKey, FrontHitchKey: from the implements saved last); + // without them the hitch length is both, and a fixed tool's distance from it is 0: the tool + // where AgOpenGPS has it. AgOpenGPS writes it negative behind the tractor; AgOpenWeb keeps + // the distance (the hitch's side gives the direction). double xmlHitch = Math.Abs(GetDouble(settings, "setVehicle_hitchLength", 1.8)); store.Vehicle.HitchLength = xmlHitch; - store.Tool.HitchLength = xmlHitch; + store.Vehicle.ThreePointHitchLength = Math.Abs(GetDouble(settings, ThreePointHitchKey, xmlHitch)); // AgOpenGPS stores the trailing hitch negative (behind the hitch); AgOpenWeb keeps the // distance. 6.8.2+ names it setVehicle_… (ToolSettings, and SettingsLegacy for a combined // file); the combined file AgOpenGPS wrote up to 6.8.1 names it setTool_… (its Settings.cs). @@ -106,6 +118,9 @@ public static void ApplyToStore(IReadOnlyDictionary settings, st store.Tool.IsToolTBT = GetBool(settings, "setTool_isToolTBT", false); store.Tool.IsToolRearFixed = GetBool(settings, "setTool_isToolRearFixed", true); store.Tool.IsToolFrontFixed = GetBool(settings, "setTool_isToolFront", false); + store.Vehicle.FrontHitchLength = Math.Abs(GetDouble(settings, FrontHitchKey, store.Tool.IsToolFrontFixed ? xmlHitch : 0)); + store.Tool.WorkingPointFromHitch = xmlHitch - (store.Tool.IsToolFrontFixed + ? store.Vehicle.FrontHitchLength : store.Vehicle.ThreePointHitchLength); store.Tool.LookAheadOnSetting = GetDouble(settings, "setVehicle_toolLookAheadOn", 1.0); store.Tool.LookAheadOffSetting = GetDouble(settings, "setVehicle_toolLookAheadOff", 0.5); store.Tool.TurnOffDelay = GetDouble(settings, "setVehicle_toolOffDelay", 0.0); diff --git a/Shared/AgOpenWeb.Services/Profile/HitchMigration.cs b/Shared/AgOpenWeb.Services/Profile/HitchMigration.cs new file mode 100644 index 000000000..7ab3a2d33 --- /dev/null +++ b/Shared/AgOpenWeb.Services/Profile/HitchMigration.cs @@ -0,0 +1,83 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Globalization; +using System.IO; +using System.Text.Json; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Tool; + +namespace AgOpenWeb.Services.Profile; + +/// +/// Profile format 3 (#547, NonTracked/HITCH_MODEL_PLAN.md): the tractor's hitch points are the +/// vehicle's (drawbar, rear 3-point, front 3-point) and a fixed tool keeps only its distance from +/// its hitch, as ISO 11783-10 chains a device's connector and elements. Format 2 kept one number +/// per tool, the rear axle → working point. Reading a format-2 tool converts it against the vehicle +/// loaded with it, so the tool stays exactly where it was. +/// +public static class HitchMigration +{ + /// The profile format this build writes. + public const int CurrentFormatVersion = 3; + + /// + /// A format-2 tool's HitchLength (rear axle → working point, positive, ahead for a front + /// tool) as format 3: the tool's distance from the vehicle's hitch, worked out so the working + /// point stays where it was. The tool's type flags are already set. The vehicle's hitch is + /// filled in when it has none: a vehicle file from before the drawbar moved to the vehicle + /// (no hitch at all) takes the tool's value for its drawbar and 3-point, as that split did; a + /// front tool on a vehicle with no front hitch gives it the tool's value. Returns what was + /// done, for the log and the operator. + /// + public static string FromAxleToWorkingPoint(ConfigurationStore store, double axleToWorkingPoint) + { + var v = store.Vehicle; + var t = store.Tool; + double old = Math.Abs(axleToWorkingPoint); + string? note = null; + if (double.IsNaN(v.HitchLength)) v.HitchLength = old; + if (double.IsNaN(v.ThreePointHitchLength)) v.ThreePointHitchLength = v.HitchLength; + + if (RigGeometry.Mount(t) == ToolMount.FrontFixed) + { + if (v.FrontHitchLength <= 0) + { + v.FrontHitchLength = old; + note = string.Create(CultureInfo.InvariantCulture, + $"the front hitch is set to {old:0.00} m ahead of the rear axle, the tool's working point at it"); + } + t.WorkingPointFromHitch = old - v.FrontHitchLength; + } + else + { + t.WorkingPointFromHitch = old - v.ThreePointHitchLength; + } + return note ?? string.Create(CultureInfo.InvariantCulture, + $"the tool's working point is now measured from the tractor's hitch: {t.WorkingPointFromHitch:0.00} m"); + } + + /// + /// A profile file's formatVersion; 0 when it has none or cannot be read (no file, damaged). + /// + public static int FormatVersionOf(string path) + { + if (!File.Exists(path)) return 0; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return 0; + foreach (var p in doc.RootElement.EnumerateObject()) + if (string.Equals(p.Name, "formatVersion", StringComparison.OrdinalIgnoreCase) + && p.Value.TryGetInt32(out int v)) + return v; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { } + return 0; + } + + /// A profile written by a newer build: this build leaves it as it is (as #825's fields and jobs). + public static bool IsNewerFormat(string path) => FormatVersionOf(path) > CurrentFormatVersion; +} diff --git a/Shared/AgOpenWeb.Services/Profile/ProfileJsonServiceV1.cs b/Shared/AgOpenWeb.Services/Profile/ProfileJsonServiceV1.cs index ce54f28d5..74f1b6dde 100644 --- a/Shared/AgOpenWeb.Services/Profile/ProfileJsonServiceV1.cs +++ b/Shared/AgOpenWeb.Services/Profile/ProfileJsonServiceV1.cs @@ -141,7 +141,7 @@ private static ProfileDto ToDto(string profileName, ConfigurationStore store) Width = store.Tool.Width, Overlap = store.Tool.Overlap, Offset = store.Tool.Offset, - HitchLength = store.Tool.HitchLength, + HitchLength = Models.Tool.RigGeometry.AxleToWorkingPoint(store.Vehicle, store.Tool), TrailingHitchLength = store.Tool.TrailingHitchLength, TankTrailingHitchLength = store.Tool.TankTrailingHitchLength, TrailingToolToPivotLength = store.Tool.TrailingToolToPivotLength, @@ -258,11 +258,13 @@ private static void ApplyDtoToStore(ProfileDto dto, string profileName, string f store.Tool.Width = dto.Tool?.Width ?? 6.0; store.Tool.Overlap = dto.Tool?.Overlap ?? 0.0; store.Tool.Offset = dto.Tool?.Offset ?? 0.0; - store.Tool.HitchLength = dto.Tool?.HitchLength ?? 1.8; - // v1 stored a single combined hitch length under Tool. After the vehicle/tool - // split the tractor hitch pin lives on the vehicle (used by trailing/TBT), so - // seed it from the same legacy value. Rigid tools keep reading Tool.HitchLength. - store.Vehicle.HitchLength = dto.Tool?.HitchLength ?? 1.8; + // v1 stored one hitch length under Tool: the tractor's hitch, and a fixed tool's working + // point at it. It is the vehicle's drawbar and 3-point; the tool's distance is converted + // after its type (below). + double v1Hitch = Math.Abs(dto.Tool?.HitchLength ?? 1.8); + store.Vehicle.HitchLength = v1Hitch; + store.Vehicle.ThreePointHitchLength = v1Hitch; + store.Vehicle.FrontHitchLength = 0; // Legacy profiles may have negative TrailingHitchLength from the old default; // migrate to "positive = behind hitch" by taking abs. store.Tool.TrailingHitchLength = Math.Abs(dto.Tool?.TrailingHitchLength ?? 2.5); @@ -272,6 +274,7 @@ private static void ApplyDtoToStore(ProfileDto dto, string profileName, string f store.Tool.IsToolTBT = dto.Tool?.IsToolTBT ?? false; store.Tool.IsToolRearFixed = dto.Tool?.IsToolRearFixed ?? true; store.Tool.IsToolFrontFixed = dto.Tool?.IsToolFrontFixed ?? false; + HitchMigration.FromAxleToWorkingPoint(store, v1Hitch); store.Tool.LookAheadOnSetting = dto.Tool?.LookAheadOn ?? Models.Configuration.ToolConfig.DefaultLookAheadOn; store.Tool.LookAheadOffSetting = dto.Tool?.LookAheadOff ?? Models.Configuration.ToolConfig.DefaultLookAheadOff; store.Tool.TurnOffDelay = dto.Tool?.TurnOffDelay ?? 0.0; diff --git a/Shared/AgOpenWeb.Services/Profile/ToolProfileJsonService.cs b/Shared/AgOpenWeb.Services/Profile/ToolProfileJsonService.cs index 8f016474f..5be0a863d 100644 --- a/Shared/AgOpenWeb.Services/Profile/ToolProfileJsonService.cs +++ b/Shared/AgOpenWeb.Services/Profile/ToolProfileJsonService.cs @@ -38,8 +38,12 @@ public static void Save(string toolsDirectory, string profileName, Configuration if (!Directory.Exists(toolsDirectory)) Directory.CreateDirectory(toolsDirectory); + // A profile from a newer build is left as it is: this build would drop what it doesn't read. + var path = GetJsonPath(toolsDirectory, profileName); + if (HitchMigration.IsNewerFormat(path)) return; + // Atomic write + .bak last-known-good (see AtomicJsonFile). - AtomicJsonFile.WriteJson(GetJsonPath(toolsDirectory, profileName), ToDto(store), Options); + AtomicJsonFile.WriteJson(path, ToDto(store), Options); } public static bool Load(string toolsDirectory, string profileName, ConfigurationStore store) @@ -116,13 +120,15 @@ internal static ToolProfileDto ToDto(ConfigurationStore store) return new ToolProfileDto { - FormatVersion = 2, + FormatVersion = HitchMigration.CurrentFormatVersion, Tool = new ToolDto { Width = store.Tool.Width, Overlap = store.Tool.Overlap, Offset = store.Tool.Offset, - HitchLength = store.Tool.HitchLength, + // Format 2's axle → working point, with this vehicle: an older build reads it. + HitchLength = Models.Tool.RigGeometry.AxleToWorkingPoint(store.Vehicle, store.Tool), + WorkingPointFromHitch = store.Tool.WorkingPointFromHitch, Length = store.Tool.Length, TrailingHitchLength = store.Tool.TrailingHitchLength, TankTrailingHitchLength = store.Tool.TankTrailingHitchLength, @@ -206,7 +212,6 @@ internal static void ApplyDtoToStore(ToolProfileDto dto, string profileName, str store.Tool.Width = dto.Tool?.Width ?? 6.0; store.Tool.Overlap = dto.Tool?.Overlap ?? 0.0; store.Tool.Offset = dto.Tool?.Offset ?? 0.0; - store.Tool.HitchLength = dto.Tool?.HitchLength ?? 1.8; store.Tool.Length = dto.Tool?.Length ?? 0.0; // Legacy AOG sign convention used negative TrailingHitchLength; canonicalize. store.Tool.TrailingHitchLength = Math.Abs(dto.Tool?.TrailingHitchLength ?? 2.5); @@ -217,6 +222,12 @@ internal static void ApplyDtoToStore(ToolProfileDto dto, string profileName, str store.Tool.IsToolTBT = dto.Tool?.IsToolTBT ?? false; store.Tool.IsToolRearFixed = dto.Tool?.IsToolRearFixed ?? true; store.Tool.IsToolFrontFixed = dto.Tool?.IsToolFrontFixed ?? false; + // The working point from the tractor's hitch (format 3); a format-2 tool's axle → working + // point is converted against the vehicle loaded with it (HitchMigration), after the type. + if (dto.FormatVersion >= 3 && dto.Tool?.WorkingPointFromHitch is double fromHitch) + store.Tool.WorkingPointFromHitch = fromHitch; + else + store.ProfileMigrationNote = $"Tool '{profileName}': " + HitchMigration.FromAxleToWorkingPoint(store, dto.Tool?.HitchLength ?? 1.8); store.Tool.LookAheadOnSetting = dto.Tool?.LookAheadOn ?? Models.Configuration.ToolConfig.DefaultLookAheadOn; store.Tool.LookAheadOffSetting = dto.Tool?.LookAheadOff ?? Models.Configuration.ToolConfig.DefaultLookAheadOff; store.Tool.TurnOffDelay = dto.Tool?.TurnOffDelay ?? 0.0; @@ -339,7 +350,11 @@ internal class ToolDto public double Width { get; set; } public double Overlap { get; set; } public double Offset { get; set; } + /// Format 2: rear axle → working point. Format 3 still writes it (with the vehicle it + /// was saved with) for older builds; it reads . public double HitchLength { get; set; } + /// Format 3: the working point from the tractor's hitch, away from the tractor. + public double? WorkingPointFromHitch { get; set; } public double Length { get; set; } public double TrailingHitchLength { get; set; } public double TankTrailingHitchLength { get; set; } diff --git a/Shared/AgOpenWeb.Services/Profile/VehicleProfileJsonService.cs b/Shared/AgOpenWeb.Services/Profile/VehicleProfileJsonService.cs index 15412255b..0cf8a5008 100644 --- a/Shared/AgOpenWeb.Services/Profile/VehicleProfileJsonService.cs +++ b/Shared/AgOpenWeb.Services/Profile/VehicleProfileJsonService.cs @@ -38,7 +38,10 @@ public static void Save(string vehiclesDirectory, string profileName, Configurat // Atomic write + .bak last-known-good (see AtomicJsonFile): a crash // mid-write can never leave a truncated profile that loads as defaults. - AtomicJsonFile.WriteJson(GetJsonPath(vehiclesDirectory, profileName), ToDto(store), Options); + var path = GetJsonPath(vehiclesDirectory, profileName); + // A profile from a newer build is left as it is: this build would drop what it doesn't read. + if (HitchMigration.IsNewerFormat(path)) return; + AtomicJsonFile.WriteJson(path, ToDto(store), Options); } public static bool Load(string vehiclesDirectory, string profileName, ConfigurationStore store) @@ -100,7 +103,7 @@ private static string GetJsonPath(string vehiclesDirectory, string profileName) internal static VehicleProfileDto ToDto(ConfigurationStore store) => new() { - FormatVersion = 2, + FormatVersion = HitchMigration.CurrentFormatVersion, Vehicle = new VehicleDto { AntennaHeight = store.Vehicle.AntennaHeight, @@ -115,6 +118,8 @@ private static string GetJsonPath(string vehiclesDirectory, string profileName) MaxSteerAngle = store.Vehicle.MaxSteerAngle, MaxAngularVelocity = store.Vehicle.MaxAngularVelocity, HitchLength = store.Vehicle.HitchLength, + ThreePointHitchLength = store.Vehicle.ThreePointHitchLength, + FrontHitchLength = store.Vehicle.FrontHitchLength, HitchType = store.Vehicle.HitchType, }, Guidance = new GuidanceDto @@ -181,6 +186,10 @@ internal static void ApplyDtoToStore(VehicleProfileDto dto, string profileName, // NaN sentinel = "not present in this file" → ConfigurationService.LoadProfiles // seeds it from the legacy Tool.HitchLength after the tool also loads. store.Vehicle.HitchLength = dto.Vehicle?.HitchLength ?? double.NaN; + // Format 3: the 3-point and front hitches. A format-2 vehicle had one hitch, the drawbar + // pin: its 3-point starts there (NaN, filled in with it when the vehicle file had none). + store.Vehicle.ThreePointHitchLength = dto.Vehicle?.ThreePointHitchLength ?? store.Vehicle.HitchLength; + store.Vehicle.FrontHitchLength = dto.Vehicle?.FrontHitchLength ?? 0.0; store.Vehicle.HitchType = dto.Vehicle?.HitchType ?? 0; // Guidance @@ -261,6 +270,10 @@ internal class VehicleDto // Nullable so a pre-split profile (no hitchLength) is detectable for one-way // migration from the legacy Tool.HitchLength (see ConfigurationService.LoadProfiles). public double? HitchLength { get; set; } + /// Format 3: the rear 3-point hitch (centre of the lower links) behind the axle. + public double? ThreePointHitchLength { get; set; } + /// Format 3: the front 3-point hitch ahead of the axle; 0 = none. + public double? FrontHitchLength { get; set; } public int HitchType { get; set; } } diff --git a/Shared/AgOpenWeb.Services/Tool/ToolPositionService.cs b/Shared/AgOpenWeb.Services/Tool/ToolPositionService.cs index 2c8ed2974..3c01d80cf 100644 --- a/Shared/AgOpenWeb.Services/Tool/ToolPositionService.cs +++ b/Shared/AgOpenWeb.Services/Tool/ToolPositionService.cs @@ -6,6 +6,7 @@ using System.Threading; using AgOpenWeb.Models.Base; using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Tool; using AgOpenWeb.Services.Interfaces; namespace AgOpenWeb.Services.Tool; @@ -62,32 +63,6 @@ public class ToolPositionService : IToolPositionService private const double TOOL_JACKKNIFE = 1.9; private const double TANK_JACKKNIFE = 2.0; - /// - /// How far the tool's working centre is behind what pulls it (the hitch, or the TBT tank): - /// the trailing hitch length plus the pivot distance. The pivot distance is AgOpenGPS's - /// (setTool_trailingToolToPivotLength; the Tool panel's Behind / Ahead): positive with the - /// tool behind its wheels, negative with it ahead. It used to be subtracted, so a tool set - /// "Behind" (or imported from AgOpenGPS) worked ahead of its wheels, twice the distance off. - /// - internal static double ToolBehindPivot(ToolConfig tool) => tool.TrailingHitchLength + tool.TrailingToolToPivotLength; - - /// - /// A trailed or TBT tool's working centre, from the tractor's hitch and the headings of the - /// TBT tank and of the tool: the geometry gives the sections and the - /// coverage. The map's implement (MainViewModel's render pull, which dead-reckons the hitch - /// between fixes) is placed with it, so the map draws the tool where it works. - /// - public static (double Easting, double Northing) TrailedToolCentre( - double hitchEasting, double hitchNorthing, double tankHeading, double toolHeading, ToolConfig tool) - { - if (tool.IsToolTBT) - { - hitchEasting -= Math.Sin(tankHeading) * tool.TankTrailingHitchLength; - hitchNorthing -= Math.Cos(tankHeading) * tool.TankTrailingHitchLength; - } - double behind = ToolBehindPivot(tool); - return (hitchEasting - Math.Sin(toolHeading) * behind, hitchNorthing - Math.Cos(toolHeading) * behind); - } private static double AngleBetween(double a, double b) => Math.Abs(Math.PI - Math.Abs(Math.Abs(a - b) - Math.PI)); @@ -143,44 +118,16 @@ public void Update(Vec3 vehiclePivot, double vehicleHeading) var tool = _configStore.Tool; var vehicle = _configStore.Vehicle; - // Calculate hitch point on vehicle. - // Two distinct measurements feed this, chosen by tool type: - // - Rigid front/rear-fixed: Tool.HitchLength = axle -> implement working - // center (tool-dependent). - // - Trailing/TBT: Vehicle.HitchLength = axle -> tractor hitch pin - // (the trailer attach point). - // Stored positive; sign applied by direction (front ahead, rest behind). - double hitchDistance = (tool.IsToolFrontFixed || tool.IsToolRearFixed) - ? Math.Abs(tool.HitchLength) - : Math.Abs(vehicle.HitchLength); - if (tool.IsToolRearFixed || tool.IsToolTrailing || tool.IsToolTBT) - { - hitchDistance = -hitchDistance; // Behind the vehicle - } - // Front fixed keeps positive (ahead of vehicle) - - _hitchPosition = new Vec3( - vehiclePivot.Easting + Math.Sin(vehicleHeading) * hitchDistance, - vehiclePivot.Northing + Math.Cos(vehicleHeading) * hitchDistance, - vehicleHeading - ); + // The point the tool hangs on (RigGeometry): a fixed tool's working point, or the + // tractor's hitch pin for a trailed one. + var mount = RigGeometry.Mount(tool); + _hitchPosition = RigGeometry.AlongTractor(vehiclePivot, vehicleHeading, RigGeometry.AttachX(vehicle, tool)); - if (tool.IsToolFrontFixed || tool.IsToolRearFixed) - { - CalculateFixedToolPosition(vehiclePivot, vehicleHeading, tool); - } - else if (tool.IsToolTBT) + switch (mount) { - CalculateTBTToolPosition(vehicleHeading, tool); - } - else if (tool.IsToolTrailing) - { - CalculateTrailingToolPosition(vehicleHeading, tool); - } - else - { - // Default: treat as fixed rear - CalculateFixedToolPosition(vehiclePivot, vehicleHeading, tool); + case ToolMount.TBT: CalculateTBTToolPosition(vehicleHeading, tool); break; + case ToolMount.Trailing: CalculateTrailingToolPosition(vehicleHeading, tool); break; + default: CalculateFixedToolPosition(vehiclePivot, vehicleHeading, tool); break; } // No lateral offset here: the section spans carry it (SectionControlService, @@ -261,7 +208,7 @@ private void CalculateTrailingToolPosition(double vehicleHeading, ToolConfig too ); // Calculate tool center position (offset from pivot by TrailingToolToPivotLength) - double pivotOffset = ToolBehindPivot(tool); + double pivotOffset = RigGeometry.ToolBehindPivot(tool); _toolPosition = new Vec3( _hitchPosition.Easting - Math.Sin(_toolHeading) * pivotOffset, _hitchPosition.Northing - Math.Cos(_toolHeading) * pivotOffset, @@ -345,7 +292,7 @@ private void CalculateTBTToolPosition(double vehicleHeading, ToolConfig tool) ); // Tool center position - double pivotOffset = ToolBehindPivot(tool); + double pivotOffset = RigGeometry.ToolBehindPivot(tool); _toolPosition = new Vec3( _tankPosition.Easting - Math.Sin(_toolHeading) * pivotOffset, _tankPosition.Northing - Math.Cos(_toolHeading) * pivotOffset, @@ -371,7 +318,7 @@ private void SnapToolBehindVehicle(double vehicleHeading, ToolConfig tool) vehicleHeading ); - double pivotOffset = ToolBehindPivot(tool); + double pivotOffset = RigGeometry.ToolBehindPivot(tool); _toolPosition = new Vec3( _hitchPosition.Easting - Math.Sin(vehicleHeading) * pivotOffset, _hitchPosition.Northing - Math.Cos(vehicleHeading) * pivotOffset, @@ -404,7 +351,7 @@ private void SnapTBTBehindVehicle(double vehicleHeading, ToolConfig tool) vehicleHeading ); - double pivotOffset = ToolBehindPivot(tool); + double pivotOffset = RigGeometry.ToolBehindPivot(tool); _toolPosition = new Vec3( _tankPosition.Easting - Math.Sin(vehicleHeading) * pivotOffset, _tankPosition.Northing - Math.Cos(vehicleHeading) * pivotOffset, @@ -468,24 +415,9 @@ public void ResetTrailingState(Vec3 vehiclePivot, double vehicleHeading) var tool = _configStore.Tool; var vehicle = _configStore.Vehicle; - // Calculate hitch position - must match Update() sign convention and - // tool-type hitch reference: rigid tools use Tool.HitchLength (working - // center), trailing/TBT use Vehicle.HitchLength (tractor hitch pin). - double hitchDistance = (tool.IsToolFrontFixed || tool.IsToolRearFixed) - ? Math.Abs(tool.HitchLength) - : Math.Abs(vehicle.HitchLength); - if (tool.IsToolRearFixed || tool.IsToolTrailing || tool.IsToolTBT) - { - hitchDistance = -hitchDistance; - } - - _hitchPosition = new Vec3( - vehiclePivot.Easting + Math.Sin(vehicleHeading) * hitchDistance, - vehiclePivot.Northing + Math.Cos(vehicleHeading) * hitchDistance, - vehicleHeading - ); + _hitchPosition = RigGeometry.AlongTractor(vehiclePivot, vehicleHeading, RigGeometry.AttachX(vehicle, tool)); - if (tool.IsToolTBT) + if (RigGeometry.Mount(tool) == ToolMount.TBT) { SnapTBTBehindVehicle(vehicleHeading, tool); } diff --git a/Shared/AgOpenWeb.Services/ToolProfileService.cs b/Shared/AgOpenWeb.Services/ToolProfileService.cs index 4f8a1bbc4..41378a860 100644 --- a/Shared/AgOpenWeb.Services/ToolProfileService.cs +++ b/Shared/AgOpenWeb.Services/ToolProfileService.cs @@ -109,7 +109,7 @@ public void CreateDefaultProfile(string profileName, ConfigurationStore store) store.Tool.Width = 6.0; store.Tool.Overlap = 0.0; store.Tool.Offset = 0.0; - store.Tool.HitchLength = 1.8; + store.Tool.WorkingPointFromHitch = 0.0; store.Tool.TrailingHitchLength = 2.5; store.Tool.TankTrailingHitchLength = 3.0; store.Tool.TrailingToolToPivotLength = 0.0; diff --git a/Shared/AgOpenWeb.Services/VehicleProfileService.cs b/Shared/AgOpenWeb.Services/VehicleProfileService.cs index 4f7099e65..b8ec4ff47 100644 --- a/Shared/AgOpenWeb.Services/VehicleProfileService.cs +++ b/Shared/AgOpenWeb.Services/VehicleProfileService.cs @@ -205,6 +205,8 @@ public void CreateDefaultProfile(string profileName, ConfigurationStore store) store.Vehicle.Wheelbase = 2.5; store.Vehicle.TrackWidth = 1.8; store.Vehicle.HitchLength = 1.8; + store.Vehicle.ThreePointHitchLength = 1.8; + store.Vehicle.FrontHitchLength = 0.0; store.Vehicle.MaxSteerAngle = 35.0; store.Vehicle.MaxAngularVelocity = 35.0; @@ -222,7 +224,7 @@ public void CreateDefaultProfile(string profileName, ConfigurationStore store) store.Tool.Width = 6.0; store.Tool.Overlap = 0.0; store.Tool.Offset = 0.0; - store.Tool.HitchLength = 1.8; + store.Tool.WorkingPointFromHitch = 0.0; store.Tool.TrailingHitchLength = 2.5; store.Tool.TankTrailingHitchLength = 3.0; store.Tool.TrailingToolToPivotLength = 0.0; diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.LineEntry.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.LineEntry.cs index 744e488dc..6aca79e03 100644 --- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.LineEntry.cs +++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.LineEntry.cs @@ -137,7 +137,8 @@ public LineEntryPlan PlanLineEntry( return LineEntryPlan.NotNeeded; var config = _configStore; - double frontReach = config.Tool.IsToolFrontFixed ? Math.Abs(config.Tool.HitchLength) : 0.0; + double frontReach = Models.Tool.RigGeometry.Mount(config.Tool) == Models.Tool.ToolMount.FrontFixed + ? Math.Abs(Models.Tool.RigGeometry.AttachX(config.Vehicle, config.Tool)) : 0.0; double lead = Math.Max(0.0, config.Guidance.LineEntryStraight) + frontReach; double goalS = best.StartS - lead; var goalPt = best.PointAt(goalS); diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.Orchestration.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.Orchestration.cs index 7aef05e5a..57323813b 100644 --- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.Orchestration.cs +++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.Orchestration.cs @@ -121,10 +121,10 @@ public TurnPathResult CreateTurnPath( // Diagnostic: one line per turn build to see whether the clearance path is // armed at runtime and with what geometry (note actualWidth vs storedWidth). _logger.LogDebug( - "[YouTurn][HardBoundary] hardOuter={Hard} isHard={IsHard} validOuter={Valid} actualWidth={AW:F1} storedWidth={W:F1} rearFixed={RF} trailing={TR} length={L:F1} margin={M:F1}", + "[YouTurn][HardBoundary] hardOuter={Hard} isHard={IsHard} validOuter={Valid} actualWidth={AW:F1} storedWidth={W:F1} mount={Mount} length={L:F1} margin={M:F1}", hardOuter, boundary?.OuterBoundary?.IsHard, boundary?.OuterBoundary?.IsValid, - config.ActualToolWidth, config.Tool.Width, config.Tool.IsToolRearFixed, - config.Tool.IsToolTrailing, config.Tool.Length, clearanceMargin); + config.ActualToolWidth, config.Tool.Width, RigGeometry.Mount(config.Tool), + config.Tool.Length, clearanceMargin); const double SetbackStep = 0.5; const double MaxExtraSetback = 30.0; @@ -371,28 +371,10 @@ public TurnPathResult CreateTurnPath( /// Maps the runtime tool/vehicle config onto the pure /// used by the implement swept-path clearance check. /// - private static ToolGeometry BuildToolGeometry(ConfigurationStore config) - { - var tool = config.Tool; - ToolMount mount = - tool.IsToolFrontFixed ? ToolMount.FrontFixed : - tool.IsToolTBT ? ToolMount.TBT : - tool.IsToolTrailing ? ToolMount.Trailing : - ToolMount.RearFixed; // rear-fixed is the default - - return new ToolGeometry( - Mount: mount, - // Real implement width comes from the active sections (ActualToolWidth), - // not Tool.Width — the latter is a stored fallback (often the 6 m default). - Width: config.ActualToolWidth, - Offset: tool.Offset, - VehicleHitchLength: config.Vehicle.HitchLength, - ToolHitchLength: tool.HitchLength, - TrailingHitchLength: tool.TrailingHitchLength, - TrailingToolToPivotLength: tool.TrailingToolToPivotLength, - TankTrailingHitchLength: tool.TankTrailingHitchLength, - Length: tool.Length); - } + // Real implement width comes from the active sections (ActualToolWidth), not Tool.Width: + // the latter is a stored fallback (often the 6 m default). + private static ToolGeometry BuildToolGeometry(ConfigurationStore config) => + RigGeometry.Geometry(config.Vehicle, config.Tool, config.ActualToolWidth); /// /// True when the implement swept along stays clear of a diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.cs index 6139ac4de..190d46aba 100644 --- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.cs +++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.cs @@ -1275,12 +1275,12 @@ private bool FindCurveTurnPoint(YouTurnCreationInput input, bool useAlternateHea /// walking back from the end the first try found towards the tractor, the first point of the /// run of points outside the moved turn area that reaches that end. Null on a first try, or /// when that end is not ahead on this pass or not outside the moved turn area. - /// The moved line only seats the turn further back. Where the fence bulges in beside the pass + /// The moved line only seats the turn further back. Where the fence comes in beside the pass /// (a narrowing of the field the pass runs past, inside the turn line all the way), the line - /// moved in crosses the pass there, and the first crossing ahead was that one: on Tapada da - /// Casa a front boom near the fence at the far end moved the turn line in 5 m, and the turn - /// was made 260 m before the end, beside the bulge, landing on the next pass 16 m after its - /// edge entered the crop, which was never sprayed. Fork only. + /// moved in crosses the pass there, and the first crossing ahead was that one: a field with an + /// 8 m boom near a hard fence at the far end had its turn line moved in 5 m, and the turn was + /// made 260 m before the end, beside the narrowing, landing on the next pass 16 m after its + /// edge entered the crop, which was never worked. /// private static int? TurnLineEndOnRetry(YouTurnCreationInput input, int count) { diff --git a/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs b/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs index 313452922..a7aa128a5 100644 --- a/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs +++ b/Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs @@ -64,7 +64,7 @@ public sealed class YouTurnStateMachine // _missedSounded: a missed turn sounds once (#570). Not re-armed by a new plan, which a // missed turn is followed by at once: only when a turn starts or U-turn is switched off. private bool _creationFailSounded, _approachAlarmed, _missedSounded; - /// The AB line heading last logged (rad), so the debug line is written when it changes. + /// The AB line heading last logged (rad), so its debug line is written when it changes. private double _loggedAbHeading = double.NaN; // The way along the pass the planned turn was built for (TURNED ROUND in Tick). private bool? _plannedSameWay; @@ -172,8 +172,8 @@ public YouTurnEffects Tick(in TickContext ctx, GuidanceWorkingState guidance, Yo double abDx = trackPointB.Easting - trackPointA.Easting; double abDy = trackPointB.Northing - trackPointA.Northing; abHeading = Math.Atan2(abDx, abDy); - // Only when it changes (fork only): every 30th count it filled two thirds of the 2000 - // lines a bug report keeps, so a report held only the last 20 minutes. + // Only when it changes: written every 30th count, it was 1273 of the 2000 lines a bug + // report keeps (a simulator report), so the report reached back only 21 minutes. if (!(Math.Abs(abHeading - _loggedAbHeading) < 1e-3)) { _loggedAbHeading = abHeading; @@ -1245,9 +1245,9 @@ private EndTried EndKey(in TickContext ctx, Models.Track.Track track, GuidanceWo var g = c.Guidance; int settings = HashCode.Combine( HashCode.Combine(g.UTurnRadius, g.UTurnDistanceFromBoundary, g.UTurnExtension, g.UTurnSmoothing, g.UTurnStyle), - HashCode.Combine(c.ActualToolWidth, c.Tool.Width, c.Tool.Overlap, c.Tool.Offset, c.Tool.Length, - c.Tool.IsToolTrailing, c.Tool.IsToolRearFixed), - HashCode.Combine(c.Vehicle.HitchLength, ctx.HeadlandCalculatedWidth, ctx.HeadlandDistance)); + HashCode.Combine(c.ActualToolWidth, c.Tool.Width, c.Tool.Overlap, + AgOpenWeb.Models.Tool.RigGeometry.Stamp(c.Vehicle, c.Tool)), + HashCode.Combine(ctx.HeadlandCalculatedWidth, ctx.HeadlandDistance)); return new EndTried(track, guidance.HowManyPathsAway, guidance.IsHeadingSameWay, guidance.NudgeOffset, turn.SnakeSequence, fieldStamp, ctx.UTurnSkipRows, turn.NextUTurnDirectionLeftOverride, settings); } diff --git a/Shared/AgOpenWeb.ViewModels/ConfigurationViewModel.cs b/Shared/AgOpenWeb.ViewModels/ConfigurationViewModel.cs index 3b96596ea..7bf5a1b86 100644 --- a/Shared/AgOpenWeb.ViewModels/ConfigurationViewModel.cs +++ b/Shared/AgOpenWeb.ViewModels/ConfigurationViewModel.cs @@ -1195,12 +1195,11 @@ private void InitializeToolEditCommands() v => Tool.Offset = v, "m", integerOnly: false, allowNegative: true, min: -5, max: 5)); - // Rigid tool (#2/#3): axle center -> implement working center (tiller/disc shaft). - // Tool-dependent; used only by front/rear-fixed tools. + // Rigid tool: its working point from the tractor's 3-point hitch (#547). EditToolHitchLengthCommand = new RelayCommand(() => - ShowNumericInput("Working Center Distance", Tool.HitchLength, - v => Tool.HitchLength = v, - "m", integerOnly: false, allowNegative: true, min: -15, max: 15)); + ShowNumericInput("Hitch to Working Point", Tool.WorkingPointFromHitch, + v => Tool.WorkingPointFromHitch = v, + "m", integerOnly: false, allowNegative: true, min: -10, max: 30)); EditToolLengthCommand = new RelayCommand(() => ShowNumericInput("Implement Length", Tool.Length, diff --git a/Shared/AgOpenWeb.ViewModels/MainViewModel.SettingsUnderAutoSteer.cs b/Shared/AgOpenWeb.ViewModels/MainViewModel.SettingsUnderAutoSteer.cs index a17a839b9..50a5287df 100644 --- a/Shared/AgOpenWeb.ViewModels/MainViewModel.SettingsUnderAutoSteer.cs +++ b/Shared/AgOpenWeb.ViewModels/MainViewModel.SettingsUnderAutoSteer.cs @@ -23,12 +23,13 @@ public partial class MainViewModel { nameof(VehicleConfig.Type), nameof(VehicleConfig.Wheelbase), nameof(VehicleConfig.AntennaHeight), nameof(VehicleConfig.AntennaPivot), nameof(VehicleConfig.AntennaOffset), nameof(VehicleConfig.HitchLength), + nameof(VehicleConfig.ThreePointHitchLength), nameof(VehicleConfig.FrontHitchLength), }; private static readonly HashSet ToolSteersBy = new(StringComparer.Ordinal) { nameof(ToolConfig.Width), nameof(ToolConfig.SectionWidths), nameof(ToolConfig.Overlap), nameof(ToolConfig.Offset), - nameof(ToolConfig.HitchLength), nameof(ToolConfig.TrailingHitchLength), nameof(ToolConfig.TankTrailingHitchLength), + nameof(ToolConfig.WorkingPointFromHitch), nameof(ToolConfig.TrailingHitchLength), nameof(ToolConfig.TankTrailingHitchLength), nameof(ToolConfig.TrailingToolToPivotLength), nameof(ToolConfig.CurrentToolType), }; @@ -71,9 +72,10 @@ private int SteeringGeometry() var s = ConfigStore; var v = s.Vehicle; var t = s.Tool; var h = new HashCode(); h.Add(v.Type); h.Add(v.Wheelbase); h.Add(v.AntennaHeight); h.Add(v.AntennaPivot); h.Add(v.AntennaOffset); h.Add(v.HitchLength); + h.Add(v.ThreePointHitchLength); h.Add(v.FrontHitchLength); h.Add(s.NumSections); for (int i = 0; i < s.NumSections; i++) h.Add(t.GetSectionWidth(i)); - h.Add(t.Width); h.Add(t.Overlap); h.Add(t.Offset); h.Add(t.HitchLength); h.Add(t.TrailingHitchLength); + h.Add(t.Width); h.Add(t.Overlap); h.Add(t.Offset); h.Add(t.WorkingPointFromHitch); h.Add(t.TrailingHitchLength); h.Add(t.TankTrailingHitchLength); h.Add(t.TrailingToolToPivotLength); h.Add(t.IsToolFrontFixed); h.Add(t.IsToolRearFixed); h.Add(t.IsToolTrailing); h.Add(t.IsToolTBT); h.Add(s.Connections.IsDualGps); h.Add(s.Connections.DualHeadingOffset); diff --git a/Shared/AgOpenWeb.ViewModels/MainViewModel.cs b/Shared/AgOpenWeb.ViewModels/MainViewModel.cs index 78c78dbf7..ed65cc572 100644 --- a/Shared/AgOpenWeb.ViewModels/MainViewModel.cs +++ b/Shared/AgOpenWeb.ViewModels/MainViewModel.cs @@ -329,8 +329,8 @@ public MainViewModel( // (load / save / picker dialog) so labels like CurrentProfileName // re-render. The store updates correctly on its own, but bindings // through computed properties on this VM need an explicit notify. - _configurationService.ProfileLoaded += (_, _) => RaiseProfileNameChanged(); - _configurationService.ProfileSaved += (_, _) => RaiseProfileNameChanged(); + _configurationService.ProfileLoaded += (_, _) => { RaiseProfileNameChanged(); RecordJobRig(); }; + _configurationService.ProfileSaved += (_, _) => { RaiseProfileNameChanged(); RecordJobRig(); }; _autoSteerService = autoSteerService; _smartWasService = smartWasService; _trackCopierService = trackCopierService; @@ -345,8 +345,9 @@ public MainViewModel( _jobService = jobService; // Refresh the field/job pill + status strip whenever the active job // changes (created, resumed, suspended). - _jobService.ActiveJobChanged += (_, _) => + _jobService.ActiveJobChanged += (_, job) => { + if (job != null) RecordJobRig(); OnPropertyChanged(nameof(CurrentJobTaskName)); OnPropertyChanged(nameof(CurrentFieldAndJobLabel)); RaiseStatusStripChanged(); @@ -702,19 +703,13 @@ private void OnRenderPullTick(object? sender, EventArgs e) var p = _positionEstimator.GetPose(renderTs); var tool = ConfigStore.Tool; - // Rigid tools use Tool.HitchLength (axle -> working center); trailing/TBT use - // Vehicle.HitchLength (axle -> tractor hitch pin). Matches ToolPositionService. - double hitchDistance = (tool.IsToolFrontFixed || tool.IsToolRearFixed) - ? Math.Abs(tool.HitchLength) - : Math.Abs(ConfigStore.Vehicle.HitchLength); - if (tool.IsToolRearFixed || tool.IsToolTrailing || tool.IsToolTBT) - hitchDistance = -hitchDistance; - - double hitchE = p.Position.Easting + Math.Sin(p.Heading) * hitchDistance; - double hitchN = p.Position.Northing + Math.Cos(p.Heading) * hitchDistance; + // The point the tool hangs on, as the tool position has it (RigGeometry). + var hitch = AgOpenWeb.Models.Tool.RigGeometry.AlongTractor(new Vec3(p.Position.Easting, p.Position.Northing, p.Heading), p.Heading, + AgOpenWeb.Models.Tool.RigGeometry.AttachX(ConfigStore.Vehicle, tool)); + double hitchE = hitch.Easting, hitchN = hitch.Northing; double toolE, toolN, toolHeading; - if (tool.IsToolFrontFixed || tool.IsToolRearFixed) + if (AgOpenWeb.Models.Tool.RigGeometry.IsRigid(AgOpenWeb.Models.Tool.RigGeometry.Mount(tool))) { // Fixed tool follows the vehicle exactly — no Torriem state. toolHeading = p.Heading; @@ -729,7 +724,7 @@ private void OnRenderPullTick(object? sender, EventArgs e) // with the tool position's own geometry: the pivot distance's // sign as AgOpenGPS's, so "Behind" is drawn behind the wheels. toolHeading = _toolPositionService.ToolHeading; - (toolE, toolN) = AgOpenWeb.Services.Tool.ToolPositionService.TrailedToolCentre( + (toolE, toolN) = AgOpenWeb.Models.Tool.RigGeometry.TrailedToolCentre( hitchE, hitchN, _toolPositionService.TankPosition.Heading, toolHeading, tool); } @@ -1944,6 +1939,10 @@ private async Task OpenFieldCoreAsync(string fieldPath, string fieldName) { _coverageMapService.LoadFromFile(fieldPath, activeJob.TaskName); _logger.LogDebug($"[Coverage] Loaded coverage from {fieldPath} job={activeJob.TaskName}"); + // A newer AgOpenWeb's job: its coverage isn't read, and nothing is written to it + // (JobJsonService.IsNewerFormat), so the work done now is not kept. + if (Services.Fields.JobJsonService.IsNewerFormat(fieldPath, activeJob.TaskName)) + ReportFailure("This job was saved by a newer AgOpenWeb: its coverage is not shown and work done in it is not saved. Start a new job, or update AgOpenWeb."); } else { @@ -2028,6 +2027,11 @@ private async Task OpenFieldCoreAsync(string fieldPath, string fieldName) FieldFullyLoaded?.Invoke(field); StatusMessage = $"Opened field: {fieldName}"; + + // A newer AgOpenWeb's field: this build reads it but writes nothing to it, so it + // can't overwrite what it doesn't understand (GeoJsonFieldService.CurrentFormatVersion). + if (Services.GeoJson.GeoJsonFieldService.IsNewerFormat(fieldPath)) + ReportFailure("This field was saved by a newer AgOpenWeb: changes to its boundaries, tracks, flags and headland are not saved. Update AgOpenWeb to edit it."); } catch (Exception ex) { @@ -3946,6 +3950,21 @@ public string CurrentProfileSummary /// status pill on ConfigurationPanel refresh after the picker dialog /// or any other profile change. /// + /// + /// The active job keeps the machine it is worked with (#547): the vehicle and tool loaded now, + /// when the job starts or resumes and whenever a profile is loaded or saved (closing a settings + /// panel saves it). The job's file is written only when the machine changed. + /// + private void RecordJobRig() + { + if (_jobService.ActiveJob == null) return; + try { _jobService.RecordRig(AgOpenWeb.Models.Job.JobRig.From(_configStore)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "[Job] Could not save the machine to job.json"); + } + } + private void RaiseProfileNameChanged() { OnPropertyChanged(nameof(CurrentProfileName)); diff --git a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/AgOpenWeb.DumpReplayScenarios.Tests.csproj b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/AgOpenWeb.DumpReplayScenarios.Tests.csproj index 966445977..a1c743b7e 100644 --- a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/AgOpenWeb.DumpReplayScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/AgOpenWeb.DumpReplayScenarios.Tests.csproj @@ -51,6 +51,7 @@ + diff --git a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/DumpReplayScenarioTests.cs b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/DumpReplayScenarioTests.cs index ba4bc332d..17d3f1c04 100644 --- a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/DumpReplayScenarioTests.cs +++ b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/DumpReplayScenarioTests.cs @@ -76,8 +76,7 @@ public async Task Replay_every_dump() } finally { - var report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.TestDirectory, "dump-replay-scenarios-report.md"); + var report = ReportPath.For("dump-replay-scenarios-report.md"); File.WriteAllText(report, journal.ToMarkdown()); TestContext.Out.WriteLine("Report: " + report); try { Directory.Delete(root, true); } catch { /* temp */ } diff --git a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/ReporterSettings.cs b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/ReporterSettings.cs index 183926a36..061f8c220 100644 --- a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/ReporterSettings.cs +++ b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/ReporterSettings.cs @@ -30,7 +30,9 @@ private static readonly (string[] Path, string Key)[] Keys = (new[] { "Vehicle", "MaxSteerAngle" }, "autosteer.maxSteerAngle"), (new[] { "Tool", "Overlap" }, "tool.overlap"), (new[] { "Tool", "Offset" }, "tool.offset"), - (new[] { "Tool", "HitchLength" }, "tool.hitchLength"), + // A dump's Tool.HitchLength is a fixed tool's axle → working point (every build's dump + // carries it): applied as the tractor's 3-point and front hitch with the tool at them (#547). + (new[] { "Tool", "HitchLength" }, "vehicle.threePointHitchLength"), (new[] { "Tool", "TrailingHitchLength" }, "tool.trailingHitchLength"), (new[] { "Tool", "TrailingToolToPivotLength" }, "tool.trailingToolToPivotLength"), (new[] { "Tool", "TankTrailingHitchLength" }, "tool.tankTrailingHitchLength"), @@ -177,6 +179,13 @@ public static (List Commands, List> Writes, L }; if (text != null) cmds.Add(key + ":" + text); } + // The fixed tool's axle → working point went to the 3-point hitch above; the front hitch + // takes it too, and the tool sits at them (#547). + if (d.Num("Tool", "HitchLength") is double axleToWorking) + { + cmds.Add("vehicle.frontHitchLength:" + Math.Abs(axleToWorking).ToString("R", Inv)); + cmds.Add("tool.workingPointFromHitch:0"); + } cmds.Add(RollFilterOff); foreach (var (path, set) in Direct) { diff --git a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/TakeOver.cs b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/TakeOver.cs index 5408ea42f..3ad5ba478 100644 --- a/Tests/AgOpenWeb.DumpReplayScenarios.Tests/TakeOver.cs +++ b/Tests/AgOpenWeb.DumpReplayScenarios.Tests/TakeOver.cs @@ -75,10 +75,10 @@ public async Task DriveAsync() var store = rig.Store; var v = new VehicleSpec("reporter's vehicle", 0, Math.Max(1.0, store.Vehicle.Wheelbase), store.Vehicle.TrackWidth, store.Vehicle.AntennaHeight, - store.Vehicle.AntennaPivot, store.Vehicle.AntennaOffset, Math.Abs(store.Tool.HitchLength), Math.Max(10, store.Vehicle.MaxSteerAngle)); + store.Vehicle.AntennaPivot, store.Vehicle.AntennaOffset, Math.Abs(store.Vehicle.HitchLength), Math.Max(10, store.Vehicle.MaxSteerAngle)); string type = store.Tool.IsToolTBT ? "tbt" : store.Tool.IsToolTrailing ? "trailing" : store.Tool.IsToolFrontFixed ? "front" : "rear"; var t = new ToolSpec("reporter's implement", type, store.ActualToolWidth, Math.Max(1, store.NumSections), store.Tool.Offset, - type is "front" or "rear" ? Math.Abs(store.Tool.HitchLength) : Math.Abs(store.Tool.TrailingHitchLength), + type is "front" or "rear" ? AgOpenWeb.Models.Tool.RigGeometry.AxleToWorkingPoint(store.Vehicle, store.Tool) : Math.Abs(store.Tool.TrailingHitchLength), store.Tool.TrailingToolToPivotLength, Math.Abs(store.Tool.TankTrailingHitchLength), store.Tool.Overlap); Truth = new TruthMachine(v, t, 0); double speedKmh = Math.Clamp(Median(rows.Where(r => r.AutoSteer && r.Speed > 0.5).Select(r => r.Speed * 3.6).ToList()), 6, 12); diff --git a/Tests/AgOpenWeb.FieldJobScenarios.Tests/AgOpenWeb.FieldJobScenarios.Tests.csproj b/Tests/AgOpenWeb.FieldJobScenarios.Tests/AgOpenWeb.FieldJobScenarios.Tests.csproj index 17a612385..c2d38e1ce 100644 --- a/Tests/AgOpenWeb.FieldJobScenarios.Tests/AgOpenWeb.FieldJobScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.FieldJobScenarios.Tests/AgOpenWeb.FieldJobScenarios.Tests.csproj @@ -55,6 +55,7 @@ + diff --git a/Tests/AgOpenWeb.FieldJobScenarios.Tests/Jobs.cs b/Tests/AgOpenWeb.FieldJobScenarios.Tests/Jobs.cs index 2a681ba00..4f4822b22 100644 --- a/Tests/AgOpenWeb.FieldJobScenarios.Tests/Jobs.cs +++ b/Tests/AgOpenWeb.FieldJobScenarios.Tests/Jobs.cs @@ -32,12 +32,15 @@ public static async Task RunFixtureAsync(IEnumerable<(string Op, JobSpec Job)> j Func toMarkdown, CancellationToken ct) { string? only = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_ONLY"); - string path = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, reportName); + string path = ReportPath.For(reportName); var journal = new Journal(); var clock = System.Diagnostics.Stopwatch.StartNew(); void Progress(string what) => TestContext.Progress.WriteLine(F($"[{clock.Elapsed:hh\\:mm\\:ss}] {what}")); var wanted = jobs.Where(j => Want(only, j.Job.Code)).ToList(); + // None of AGOPENWEB_SCENARIO_ONLY's jobs is in this fixture: no report, which would read + // as a clean run of jobs that never ran. + if (wanted.Count == 0) + Assert.Ignore($"none of AGOPENWEB_SCENARIO_ONLY={only} is in this fixture"); for (int i = 0; i < wanted.Count; i++) { var (op, job) = wanted[i]; @@ -141,7 +144,18 @@ void flag(Severity sev, string code, string text) // ---- the field done if (r.EndedEarly) + { sc.Note(F($"the job ended early ({r.EndedBy}): {Pct(a.Sprayed, a.Work)} of the cultivated area sprayed by then")); + // The operator took over before any U-turn: the job never got past its first pass. A + // turn blocked (or missed) at the first end is noted as what the app said, and every + // coverage check passes on nothing sprayed, so J7 sprayed 0 % night after night with + // the run green (2026-11-13). A refused turn is TURN-REFUSED already; no room on the + // next pass is the end of the field. + if (r.TookOverAt >= 0 && r.Turns.Count == 0 && !r.ShortStopped + && !r.Problems.Any(p => p.Contains("refused the U-turn", StringComparison.Ordinal) + || p.Contains("had no room for a U-turn", StringComparison.Ordinal))) + flag(Severity.Bug, "JOB-NEVER-TURNED", F($"the job never got past its first pass: the operator took over before any U-turn ({r.EndedBy}), with {Pct(a.Sprayed, a.Work)} of the cultivated area sprayed.")); + } else if (!r.FieldDone) flag(Severity.Bug, "FIELD-NOT-FINISHED", F($"AgOpenWeb never reported the end of the field; the job stopped after {r.Seconds / 60:0} min with {Pct(a.Missed, a.Work)} of the cultivated area unsprayed. {string.Join(" | ", r.Problems)}")); else if (a.Missed - a.MissedBetweenPasses - a.MissedLeftInMiddle - a.MissedAtCorners > 0.05 * a.Work) diff --git a/Tests/AgOpenWeb.FieldJobScenarios.Tests/Machines.cs b/Tests/AgOpenWeb.FieldJobScenarios.Tests/Machines.cs index 3c2f33102..99c591f5e 100644 --- a/Tests/AgOpenWeb.FieldJobScenarios.Tests/Machines.cs +++ b/Tests/AgOpenWeb.FieldJobScenarios.Tests/Machines.cs @@ -92,7 +92,14 @@ public IEnumerable Settings() yield return "tool.type:" + Type; yield return "tool.numSections:" + Sections.ToString(i); for (int s = 0; s < Sections; s++) yield return "tool.sectionWidth:" + s.ToString(i) + "," + SectionCm(s).ToString("0.###", i); - if (Type is "front" or "rear") yield return "tool.hitchLength:" + HitchLength.ToString(i); + // A fixed implement's working point HitchLength from the rear axle: the tractor's 3-point + // (front hitch for a front implement) there and the implement at it (#547), as the + // AgOpenGPS import does. + if (Type is "front" or "rear") + { + yield return (Type == "front" ? "vehicle.frontHitchLength:" : "vehicle.threePointHitchLength:") + HitchLength.ToString(i); + yield return "tool.workingPointFromHitch:0"; + } else { yield return "tool.trailingHitchLength:" + HitchLength.ToString(i); diff --git a/Tests/AgOpenWeb.FieldJobScenarios.Tests/README.md b/Tests/AgOpenWeb.FieldJobScenarios.Tests/README.md index 75a13e8c7..97fcc63d4 100644 --- a/Tests/AgOpenWeb.FieldJobScenarios.Tests/README.md +++ b/Tests/AgOpenWeb.FieldJobScenarios.Tests/README.md @@ -45,6 +45,7 @@ its neighbours': |---|---| | Ground never sprayed between neighbouring passes, widest strip (wedges at the pass ends after AgOpenWeb said "Headland too narrow for this U-turn" are a note: the turn leaves its pass inside the headland line) | `MISSED-STRIPS` | | The field reported done with ground left (inside the worked passes, or on a side of the field / in passes skipped), or never reported done | `FIELD-LEFT-UNDONE`, `FIELD-NOT-FINISHED` | +| The operator took over before any U-turn (a turn blocked or missed at the first end): the job never got past its first pass, whatever the coverage checks say about nothing sprayed | `JOB-NEVER-TURNED` | | "End of field reached" said, then a U-turn onto another pass after all (the operator drives on) | `END-OF-FIELD-TOO-EARLY` | | Sprayed outside the boundary, inside an obstacle, inside the headland further past its line than a section's width (a section is in the headland only with both ends in it, as AgOpenGPS, so where a pass meets the line at an angle up to a section's width lands past it) | `SPRAYED-OUTSIDE-BOUNDARY`, `SPRAYED-IN-OBSTACLE`, `SPRAYED-IN-HEADLAND` | | AgOpenWeb's map painted where no valve was open, or blank where one was | `MAP-SHOWS-UNSPRAYED`, `SPRAYED-NOT-ON-MAP` | diff --git a/Tests/AgOpenWeb.FieldScenarios.Tests/AgOpenWeb.FieldScenarios.Tests.csproj b/Tests/AgOpenWeb.FieldScenarios.Tests/AgOpenWeb.FieldScenarios.Tests.csproj index ca82376bd..b6e68955b 100644 --- a/Tests/AgOpenWeb.FieldScenarios.Tests/AgOpenWeb.FieldScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.FieldScenarios.Tests/AgOpenWeb.FieldScenarios.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/Tests/AgOpenWeb.FieldScenarios.Tests/FieldScenarioTests.cs b/Tests/AgOpenWeb.FieldScenarios.Tests/FieldScenarioTests.cs index f9ea38467..ece810e3e 100644 --- a/Tests/AgOpenWeb.FieldScenarios.Tests/FieldScenarioTests.cs +++ b/Tests/AgOpenWeb.FieldScenarios.Tests/FieldScenarioTests.cs @@ -38,8 +38,7 @@ public async Task Boot() [OneTimeTearDown] public async Task WriteReport() { - var path = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.TestDirectory, "field-scenarios-report.md"); + var path = ReportPath.For("field-scenarios-report.md"); var md = _journal.ToMarkdown("Field scenarios: the careful and the careless operator", "Generated by Tests/AgOpenWeb.FieldScenarios.Tests. Every step is a command the web client sends; " + "after each one the field state is checked against the rules in FieldInvariants.cs. " + diff --git a/Tests/AgOpenWeb.GpsNtripScenarios.Tests/AgOpenWeb.GpsNtripScenarios.Tests.csproj b/Tests/AgOpenWeb.GpsNtripScenarios.Tests/AgOpenWeb.GpsNtripScenarios.Tests.csproj index 94bb203aa..1ff1f1eca 100644 --- a/Tests/AgOpenWeb.GpsNtripScenarios.Tests/AgOpenWeb.GpsNtripScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.GpsNtripScenarios.Tests/AgOpenWeb.GpsNtripScenarios.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/Tests/AgOpenWeb.GpsNtripScenarios.Tests/GpsNtripScenarioTests.cs b/Tests/AgOpenWeb.GpsNtripScenarios.Tests/GpsNtripScenarioTests.cs index 3b011bb7c..e94ef6c30 100644 --- a/Tests/AgOpenWeb.GpsNtripScenarios.Tests/GpsNtripScenarioTests.cs +++ b/Tests/AgOpenWeb.GpsNtripScenarios.Tests/GpsNtripScenarioTests.cs @@ -27,8 +27,7 @@ public async Task Careful_and_careless_operator() if (who is null or "careful") await CarefulOperator.RunAsync(journal, only); if (who is null or "careless") await CarelessOperator.RunAsync(journal, only); - string report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "gps-ntrip-scenarios-report.md"); + string report = ReportPath.For("gps-ntrip-scenarios-report.md"); File.WriteAllText(report, journal.ToMarkdown()); TestContext.Out.WriteLine("Report: " + report); foreach (var g in journal.Findings.Values.OrderBy(v => v.First.Severity)) diff --git a/Tests/AgOpenWeb.IsobusScenarios.Tests/AgOpenWeb.IsobusScenarios.Tests.csproj b/Tests/AgOpenWeb.IsobusScenarios.Tests/AgOpenWeb.IsobusScenarios.Tests.csproj index 42381aa3c..b294dde86 100644 --- a/Tests/AgOpenWeb.IsobusScenarios.Tests/AgOpenWeb.IsobusScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.IsobusScenarios.Tests/AgOpenWeb.IsobusScenarios.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/Tests/AgOpenWeb.IsobusScenarios.Tests/IsobusScenarioTests.cs b/Tests/AgOpenWeb.IsobusScenarios.Tests/IsobusScenarioTests.cs index 3d5e4c5b9..5ccad2fbf 100644 --- a/Tests/AgOpenWeb.IsobusScenarios.Tests/IsobusScenarioTests.cs +++ b/Tests/AgOpenWeb.IsobusScenarios.Tests/IsobusScenarioTests.cs @@ -27,8 +27,7 @@ public async Task Careful_and_careless_operator() if (who is null or "careful") await CarefulOperator.RunAsync(journal, only); if (who is null or "careless") await CarelessOperator.RunAsync(journal, only); - string report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "isobus-scenarios-report.md"); + string report = ReportPath.For("isobus-scenarios-report.md"); File.WriteAllText(report, journal.ToMarkdown()); TestContext.Out.WriteLine("Report: " + report); foreach (var g in journal.Findings.Values.OrderBy(v => v.First.Severity)) diff --git a/Tests/AgOpenWeb.MachineModuleScenarios.Tests/AgOpenWeb.MachineModuleScenarios.Tests.csproj b/Tests/AgOpenWeb.MachineModuleScenarios.Tests/AgOpenWeb.MachineModuleScenarios.Tests.csproj index 5c73393d7..fe5b90a4e 100644 --- a/Tests/AgOpenWeb.MachineModuleScenarios.Tests/AgOpenWeb.MachineModuleScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.MachineModuleScenarios.Tests/AgOpenWeb.MachineModuleScenarios.Tests.csproj @@ -47,6 +47,7 @@ + diff --git a/Tests/AgOpenWeb.MachineModuleScenarios.Tests/MachineModuleScenarioTests.cs b/Tests/AgOpenWeb.MachineModuleScenarios.Tests/MachineModuleScenarioTests.cs index fd97b58cc..78fb51423 100644 --- a/Tests/AgOpenWeb.MachineModuleScenarios.Tests/MachineModuleScenarioTests.cs +++ b/Tests/AgOpenWeb.MachineModuleScenarios.Tests/MachineModuleScenarioTests.cs @@ -27,8 +27,7 @@ public async Task Careful_and_careless_operator() if (who is null or "careful") await CarefulOperator.RunAsync(journal, only); if (who is null or "careless") await CarelessOperator.RunAsync(journal, only); - string report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "machine-module-scenarios-report.md"); + string report = ReportPath.For("machine-module-scenarios-report.md"); File.WriteAllText(report, journal.ToMarkdown()); TestContext.Out.WriteLine("Report: " + report); foreach (var g in journal.Findings.Values.OrderBy(v => v.First.Severity)) diff --git a/Tests/AgOpenWeb.Models.Tests/ClosedPassSeamTests.cs b/Tests/AgOpenWeb.Models.Tests/ClosedPassSeamTests.cs index 0a9fb42f4..a942e18d5 100644 --- a/Tests/AgOpenWeb.Models.Tests/ClosedPassSeamTests.cs +++ b/Tests/AgOpenWeb.Models.Tests/ClosedPassSeamTests.cs @@ -11,7 +11,7 @@ namespace AgOpenWeb.Models.Tests; /// -/// A closed track's pass at its seam (fork only). The loop's last point repeats its first, and the +/// A closed track's pass at its seam. The loop's last point repeats its first, and the /// offset moved that point twice: once along the last stretch's normal, once along the first's. /// On the inside of the bend there the second copy lies behind the first, so the pass ran past the /// seam and back (0.2 m on a 26 m lap): its closing stretch pointed backwards, and a tractor a little diff --git a/Tests/AgOpenWeb.Models.Tests/ImplementSweptPathTests.cs b/Tests/AgOpenWeb.Models.Tests/ImplementSweptPathTests.cs index aa0bc8373..f89160fe0 100644 --- a/Tests/AgOpenWeb.Models.Tests/ImplementSweptPathTests.cs +++ b/Tests/AgOpenWeb.Models.Tests/ImplementSweptPathTests.cs @@ -21,7 +21,11 @@ private static ToolGeometry Geom(ToolMount mount, double width = 4.0, double off double vehicleHitch = 1.0, double toolHitch = 2.0, double trailingHitch = 3.0, double toolToPivot = 0.0, double tankHitch = 2.5, double length = 0.0) - => new(mount, width, offset, vehicleHitch, toolHitch, trailingHitch, toolToPivot, tankHitch, length); + => new(mount, width, offset, + // The attach point as the tool position has it: a fixed tool's working point + // (toolHitch from the axle), a trailed tool's drawbar pin (vehicleHitch). + mount == ToolMount.FrontFixed ? toolHitch : mount == ToolMount.RearFixed ? -toolHitch : -vehicleHitch, + trailingHitch, toolToPivot, tankHitch, length); /// Straight path heading north (E=0), points 0.5 m apart. private static List StraightNorth(int count = 40, double spacing = 0.5) diff --git a/Tests/AgOpenWeb.Models.Tests/TurnClearanceTests.cs b/Tests/AgOpenWeb.Models.Tests/TurnClearanceTests.cs index 0eeef6dca..e62a2bd0e 100644 --- a/Tests/AgOpenWeb.Models.Tests/TurnClearanceTests.cs +++ b/Tests/AgOpenWeb.Models.Tests/TurnClearanceTests.cs @@ -85,11 +85,11 @@ public void SweptFootprint_RearCornerPokingOut_IsNotClear() var path = new List(); for (int i = 0; i < 10; i++) path.Add(new Vec3(50, 95 + i * 0.2, 0)); // ends ~96.8, heading north var geom = new ToolGeometry(ToolMount.RearFixed, Width: 4, Offset: 0, - VehicleHitchLength: 0, ToolHitchLength: 0, TrailingHitchLength: 0, + AttachX: 0, TrailingHitchLength: 0, TrailingToolToPivotLength: 0, TankTrailingHitchLength: 0, Length: 0); // Front-fixed so the body extends NORTH (toward the top edge) past 100. - var frontGeom = geom with { Mount = ToolMount.FrontFixed, ToolHitchLength = 2, Length = 6 }; + var frontGeom = geom with { Mount = ToolMount.FrontFixed, AttachX = 2, Length = 6 }; var swept = ImplementSweptPath.Compute(path, frontGeom); var r = TurnClearance.Evaluate(swept, Square(), TurnClearance.KeepSide.Inside, margin: 0.5); diff --git a/Tests/AgOpenWeb.Models.Tests/VehicleToolLimitsTests.cs b/Tests/AgOpenWeb.Models.Tests/VehicleToolLimitsTests.cs index a079d7f2f..b5243580c 100644 --- a/Tests/AgOpenWeb.Models.Tests/VehicleToolLimitsTests.cs +++ b/Tests/AgOpenWeb.Models.Tests/VehicleToolLimitsTests.cs @@ -53,7 +53,9 @@ private static ConfigurationStore Sprayer() [TestCase("tool.offset", "250")] [TestCase("tool.overlap", "8")] [TestCase("tool.overlap", "20")] - [TestCase("tool.hitchLength", "150")] + [TestCase("tool.workingPointFromHitch", "150")] + [TestCase("vehicle.threePointHitchLength", "-1.5")] + [TestCase("vehicle.threePointHitchLength", "150")] [TestCase("tool.trailingHitchLength", "-4")] [TestCase("tool.trailingHitchLength", "0")] [TestCase("tool.tankTrailingHitchLength", "-3")] diff --git a/Tests/AgOpenWeb.RemoteServer.Tests/IconReferenceTests.cs b/Tests/AgOpenWeb.RemoteServer.Tests/IconReferenceTests.cs new file mode 100644 index 000000000..2cbb39156 --- /dev/null +++ b/Tests/AgOpenWeb.RemoteServer.Tests/IconReferenceTests.cs @@ -0,0 +1,63 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace AgOpenWeb.RemoteServer.Tests; + +/// +/// Every picture the web client names is in wwwroot/icons: a literal /icons/X.png, and the +/// lists of picture names it picks from (the measurement diagrams, chosen by the vehicle or tool type). +/// A missing one is an empty box in a settings panel, which no other check sees (the hitch diagrams, +/// redrawn for #547, replaced two of AgOpenGPS's). +/// +[TestFixture] +public class IconReferenceTests +{ + [Test] + public void Every_picture_the_client_names_exists() + { + var www = Path.Combine(RepoRoot(), "Shared", "AgOpenWeb.RemoteServer", "wwwroot"); + // By the exact name: a Linux host serves files case-sensitively (a Mac's disk would find "About.png" for "about"). + var icons = Directory.GetFiles(Path.Combine(www, "icons"), "*.png").Select(Path.GetFileNameWithoutExtension).ToHashSet(StringComparer.Ordinal); + bool Exists(string n) => icons.Contains(n); + var names = new SortedSet(StringComparer.Ordinal); + foreach (var f in Directory.GetFiles(Path.Combine(www, "js"), "*.js").Append(Path.Combine(www, "index.html"))) + { + var src = File.ReadAllText(f); + foreach (Match m in Regex.Matches(src, @"/icons/([A-Za-z0-9_-]+)\.png")) + names.Add(m.Groups[1].Value); + // The lists of names a picture is chosen from (by the vehicle or tool type): every list in a + // setImg(...) call or on a line that builds an /icons/ path, and any list in which one name is + // a picture (a list of pictures, so every name in it must be one). + const string List = @"\[((?:\s*'[A-Za-z0-9_-]*'\s*,?)+)\]"; + static IEnumerable Items(Match list) => + Regex.Matches(list.Groups[1].Value, @"'([A-Za-z0-9_-]+)'").Select(n => n.Groups[1].Value); + var chosen = Regex.Matches(src, @"setImg\('[^']*',.*?\);", RegexOptions.Singleline).Select(m => m.Value) + .Concat(src.Split('\n').Where(l => l.Contains("/icons/", StringComparison.Ordinal))); + foreach (var text in chosen) + foreach (Match list in Regex.Matches(text, List)) + names.UnionWith(Items(list)); + foreach (Match list in Regex.Matches(src, List)) + if (Items(list).Any(Exists)) names.UnionWith(Items(list)); + } + Assert.That(names, Does.Contain("ToolHitch3ptRear").And.Contain("VehicleHitch3ptFront"), "the scan finds the hitch diagrams"); + var missing = names.Where(n => !Exists(n)).ToList(); + Assert.That(missing, Is.Empty, "named by the web client but not in wwwroot/icons"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AgOpenWeb.sln"))) + dir = dir.Parent; + Assert.That(dir, Is.Not.Null, "Could not locate repo root (AgOpenWeb.sln) from test base dir."); + return dir!.FullName; + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/AgOpenGpsProfileImportTests.cs b/Tests/AgOpenWeb.Services.Tests/AgOpenGpsProfileImportTests.cs index 5d7360c08..2e4ab43d0 100644 --- a/Tests/AgOpenWeb.Services.Tests/AgOpenGpsProfileImportTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/AgOpenGpsProfileImportTests.cs @@ -170,7 +170,7 @@ public void TankHitch_SavedNegativeByAgOpenGps_PutsTheTankBehindTheHitch() Assert.That(store.Tool.TankTrailingHitchLength, Is.EqualTo(1.8)); // Heading north, straight behind the tractor: the tool is 4.2 m south of the hitch. - var (easting, northing) = ToolPositionService.TrailedToolCentre(0, 0, 0.0, 0.0, store.Tool); + var (easting, northing) = AgOpenWeb.Models.Tool.RigGeometry.TrailedToolCentre(0, 0, 0.0, 0.0, store.Tool); Assert.That(easting, Is.EqualTo(0.0).Within(1e-9)); Assert.That(northing, Is.EqualTo(-4.2).Within(1e-9), "tank and tool behind the hitch (-0.6 with the tank ahead of it)"); } @@ -290,9 +290,14 @@ public void Run_ConvertsEveryTractorAndImplement_AndDeletesTheCopies() Assert.That(autoSteer["Case"].ProportionalGain, Is.EqualTo(120)); Assert.That(autoSteer["Case"].MinSteerSpeed, Is.EqualTo(1.2)); + // The mounted sprayer's 1.6 m is the tractor's rear 3-point now, the sprayer at it (#547): + // on the tractor, its working point where AgOpenGPS has it. + Assert.That(store.Vehicle.ThreePointHitchLength, Is.EqualTo(1.6), "the 3-point, from the mounted implement"); var sprayer = new ConfigurationStore(); + Assert.That(VehicleProfileJsonService.Load(vehicles, "Case", sprayer), Is.True); Assert.That(tools.Load("Sprayer", sprayer), Is.True); - Assert.That(sprayer.Tool.HitchLength, Is.EqualTo(1.6), "the distance, not 0"); + Assert.That(sprayer.Tool.WorkingPointFromHitch, Is.EqualTo(0).Within(1e-9)); + Assert.That(AgOpenWeb.Models.Tool.RigGeometry.AxleToWorkingPoint(sprayer.Vehicle, sprayer.Tool), Is.EqualTo(1.6).Within(1e-9), "the distance, not 0"); var tool = new ConfigurationStore(); Assert.That(tools.Load("Seeder", tool), Is.True); diff --git a/Tests/AgOpenWeb.Services.Tests/AsymmetricSectionTurnTests.cs b/Tests/AgOpenWeb.Services.Tests/AsymmetricSectionTurnTests.cs index 97928439c..cfc24376c 100644 --- a/Tests/AgOpenWeb.Services.Tests/AsymmetricSectionTurnTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/AsymmetricSectionTurnTests.cs @@ -70,7 +70,9 @@ public void SetUp() config.Tool.SetSectionWidth(1, 500); // 5m right (outer during left turn) // Fixed rear at rear axle (worst case for inner edge reversal) - config.Tool.HitchLength = 0; + // A fixed tool's working point 0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 0; config.Tool.IsToolRearFixed = true; config.Tool.IsToolTrailing = false; diff --git a/Tests/AgOpenWeb.Services.Tests/AutoSteerUTurnNUnitTests.cs b/Tests/AgOpenWeb.Services.Tests/AutoSteerUTurnNUnitTests.cs index fb8dec319..0eca53369 100644 --- a/Tests/AgOpenWeb.Services.Tests/AutoSteerUTurnNUnitTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/AutoSteerUTurnNUnitTests.cs @@ -75,7 +75,9 @@ public void SetUp() // speed limits (#106) have their own tests (SteerSpeedLimitTests). config.AutoSteer.MaxSteerSpeed = 0; config.Tool.Width = TOOL_WIDTH; // 6m - config.Tool.HitchLength = 3.0; + // A fixed tool's working point 3.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(3.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 3.0; config.Tool.IsToolTrailing = true; config.Tool.IsToolRearFixed = false; diff --git a/Tests/AgOpenWeb.Services.Tests/FieldFormatVersionTests.cs b/Tests/AgOpenWeb.Services.Tests/FieldFormatVersionTests.cs new file mode 100644 index 000000000..775f8d875 --- /dev/null +++ b/Tests/AgOpenWeb.Services.Tests/FieldFormatVersionTests.cs @@ -0,0 +1,183 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Nodes; +using AgOpenWeb.Models; +using AgOpenWeb.Models.Base; +using AgOpenWeb.Models.Guidance; +using AgOpenWeb.Services.GeoJson; +using TrackModel = AgOpenWeb.Models.Track.Track; + +namespace AgOpenWeb.Services.Tests; + +/// +/// field.geojson is versioned and extensible, so a field saved by one build is safe in another +/// (Docs/FIELD_FILE_FORMATS.md): a newer build's added properties and roles survive an older +/// build's saves, and a field in a newer format version is read but never written. +/// +[TestFixture] +public class FieldFormatVersionTests +{ + private string _dir = null!; + private string FilePath => Path.Combine(_dir, "field.geojson"); + + [SetUp] + public void SetUp() + { + _dir = Path.Combine(Path.GetTempPath(), $"agopenweb_fmt_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_dir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_dir, true); } catch { } + } + + private Field NewField() => new() + { + Name = "F", + DirectoryPath = _dir, + Origin = new Position { Latitude = 52.0, Longitude = 5.0 }, + Boundary = new Boundary { OuterBoundary = Square(0, 100), InnerBoundaries = { Square(40, 50) } }, + }; + + private static BoundaryPolygon Square(double min, double max) + { + var p = new BoundaryPolygon(); + foreach (var (e, n) in new[] { (min, min), (max, min), (max, max), (min, max) }) + p.Points.Add(new BoundaryPoint(e, n, 0)); + p.UpdateBounds(); + return p; + } + + /// A field with every part this build writes. + private void SaveWholeField() + { + GeoJsonFieldService.Save(NewField(), tracks: null); + GeoJsonFieldService.SaveTracks(_dir, new[] { TrackModel.FromABLine("A", new Vec3(10, 10, 0), new Vec3(10, 90, 0)) }); + GeoJsonFieldService.SaveFlags(_dir, new[] { new Flag(20, 30, FlagColor.Blue, 7, "Rock") { Notes = "big one" } }); + GeoJsonFieldService.SaveHeadlandLine(_dir, new HeadlandLine + { + Tracks = { new HeadlandPath { Name = "H", MoveDistance = 12, TrackPoints = { new Vec3(5, 5, 0), new Vec3(95, 5, 0) } } }, + }); + GeoJsonFieldService.SaveBackground(_dir, new FieldBackground("background.png", 52.001, 4.999, 51.999, 5.001, null)); + } + + private JsonObject ReadJson() => JsonNode.Parse(File.ReadAllText(FilePath))!.AsObject(); + + private void WriteJson(JsonObject o) => File.WriteAllText(FilePath, o.ToJsonString()); + + private static IEnumerable Features(JsonObject o) => o["features"]!.AsArray().Select(f => f!.AsObject()); + + private static JsonObject Props(JsonObject feature) => feature["properties"]!.AsObject(); + + private static string Role(JsonObject feature) => Props(feature)["role"]!.GetValue(); + + /// Every part's own properties as this build's load and save leave them. + private static Dictionary Marks(JsonObject o) => + Features(o).Where(f => Props(f)["newThing"] != null) + .ToDictionary(f => Role(f) + (Props(f)["name"]?.ToString() ?? ""), f => Props(f)["newThing"]!.ToJsonString()); + + /// The app's own saves of each part, after loading it. + private void LoadAndSaveEveryPart() + { + var (field, _) = GeoJsonFieldService.Load(_dir); + GeoJsonFieldService.Save(field, tracks: null); + GeoJsonFieldService.SaveTracks(_dir, GeoJsonFieldService.LoadTracks(_dir)); + GeoJsonFieldService.SaveFlags(_dir, GeoJsonFieldService.LoadFlags(_dir)); + GeoJsonFieldService.SaveHeadlandLine(_dir, GeoJsonFieldService.LoadHeadlandLine(_dir)); + GeoJsonFieldService.SaveBackground(_dir, GeoJsonFieldService.LoadBackground(_dir)); + } + + [Test] + public void A_field_is_written_with_its_format_version_and_one_without_reads_as_version_1() + { + GeoJsonFieldService.Save(NewField(), tracks: null); + var meta = Features(ReadJson()).Single(f => Role(f) == "metadata"); + Assert.That(Props(meta)["formatVersion"]!.GetValue(), Is.EqualTo(GeoJsonFieldService.CurrentFormatVersion)); + + var o = ReadJson(); + Props(Features(o).Single(f => Role(f) == "metadata")).Remove("formatVersion"); + WriteJson(o); + Assert.That(GeoJsonFieldService.FormatVersion(_dir), Is.EqualTo(1)); + Assert.That(GeoJsonFieldService.IsNewerFormat(_dir), Is.False); + } + + [Test] + public void A_newer_builds_properties_and_roles_survive_this_builds_saves() + { + SaveWholeField(); + var o = ReadJson(); + int k = 0; + foreach (var f in Features(o)) + Props(f)["newThing"] = new JsonObject { ["v"] = ++k, ["s"] = "x" }; // a property on every part + o["features"]!.AsArray().Add(new JsonObject // a role this build doesn't know + { + ["type"] = "Feature", + ["id"] = "zone-1", + ["geometry"] = new JsonObject { ["type"] = "Point", ["coordinates"] = new JsonArray(5.0, 52.0) }, + ["properties"] = new JsonObject { ["role"] = "rate-zone", ["rate"] = 120 }, + }); + o["bbox"] = new JsonArray(4.9, 51.9, 5.1, 52.1); // a member of the collection + WriteJson(o); + var before = Marks(ReadJson()); + Assume.That(before, Has.Count.EqualTo(Features(ReadJson()).Count() - 1), "every part but the new role is marked"); + + LoadAndSaveEveryPart(); + + var after = ReadJson(); + Assert.Multiple(() => + { + Assert.That(Marks(after), Is.EquivalentTo(before), "each part keeps its own unknown property"); + var zone = Features(after).SingleOrDefault(f => Role(f) == "rate-zone"); + Assert.That(zone, Is.Not.Null, "the unknown role is kept"); + Assert.That(zone?["id"]?.GetValue(), Is.EqualTo("zone-1"), "with its feature members"); + Assert.That(Props(zone!)["rate"]!.GetValue(), Is.EqualTo(120)); + Assert.That(after["bbox"]?.AsArray().Count, Is.EqualTo(4), "the collection's members are kept"); + }); + } + + [Test] + public void An_unknown_property_never_brings_back_one_this_build_removed() + { + SaveWholeField(); + var o = ReadJson(); + Props(Features(o).Single(f => Role(f) == "flag"))["newThing"] = 1; + WriteJson(o); + + var flag = GeoJsonFieldService.LoadFlags(_dir).Single(); + flag.Notes = ""; // the operator clears the notes: the builder writes none + GeoJsonFieldService.SaveFlags(_dir, new[] { flag }); + + var props = Props(Features(ReadJson()).Single(f => Role(f) == "flag")); + Assert.Multiple(() => + { + Assert.That(props.ContainsKey("notes"), Is.False, "a property this build reads is never kept as unknown"); + Assert.That(props["newThing"]!.GetValue(), Is.EqualTo(1)); + }); + } + + [Test] + public void A_field_in_a_newer_format_is_read_but_never_written() + { + SaveWholeField(); + var o = ReadJson(); + Props(Features(o).Single(f => Role(f) == "metadata"))["formatVersion"] = GeoJsonFieldService.CurrentFormatVersion + 1; + WriteJson(o); + var bytes = File.ReadAllText(FilePath); + + Assert.That(GeoJsonFieldService.IsNewerFormat(_dir), Is.True); + var (field, tracks) = GeoJsonFieldService.Load(_dir); + Assert.That(field.Boundary?.OuterBoundary?.Points, Has.Count.EqualTo(4), "it is read"); + Assert.That(tracks, Has.Count.EqualTo(1)); + + LoadAndSaveEveryPart(); + GeoJsonFieldService.SaveTracks(_dir, Array.Empty()); + field.Boundary = new Boundary(); + GeoJsonFieldService.Save(field, tracks: null); + + Assert.That(File.ReadAllText(FilePath), Is.EqualTo(bytes), "no save changed the file"); + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/HitchModelTests.cs b/Tests/AgOpenWeb.Services.Tests/HitchModelTests.cs new file mode 100644 index 000000000..45e4804c0 --- /dev/null +++ b/Tests/AgOpenWeb.Services.Tests/HitchModelTests.cs @@ -0,0 +1,279 @@ +// AgOpenWeb +// Copyright (C) 2024-2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.IO; +using AgOpenWeb.Models; +using AgOpenWeb.Models.Base; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Services; +using AgOpenWeb.Services.Interfaces; +using AgOpenWeb.Services.Profile; +using AgOpenWeb.Services.Tool; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace AgOpenWeb.Services.Tests; + +/// +/// The hitch model (#547, NonTracked/HITCH_MODEL_PLAN.md): the tractor's hitch points are the +/// vehicle's (drawbar, rear 3-point, front 3-point, from the rear axle) and a fixed tool keeps its +/// working point's distance from its hitch, as ISO 11783-10 chains a connector and a device +/// element. Profile format 3; a format-2 tool (one number, rear axle → working point) is converted +/// against the vehicle loaded with it, so it stays where it was. +/// +[TestFixture] +[NonParallelizable] // ConfigurationStore singleton + per-test temp dirs. +public class HitchModelTests +{ + private string _tempRoot = null!; + private string _vehiclesDir = null!; + private string _toolsDir = null!; + private TestVehicleProfileService _vehicleService = null!; + private TestToolProfileService _toolService = null!; + private ISettingsService _settingsService = null!; + private AppSettings _settings = null!; + private ConfigurationService _configService = null!; + + [SetUp] + public void SetUp() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "AgOpenWeb-HitchModel-" + Guid.NewGuid().ToString("N")); + _vehiclesDir = Path.Combine(_tempRoot, "Vehicles"); + _toolsDir = Path.Combine(_tempRoot, "Tools"); + Directory.CreateDirectory(_vehiclesDir); + Directory.CreateDirectory(_toolsDir); + + _vehicleService = new TestVehicleProfileService(_vehiclesDir); + _toolService = new TestToolProfileService(_toolsDir); + + _settings = new AppSettings(); + _settingsService = Substitute.For(); + _settingsService.Settings.Returns(_settings); + + ConfigurationStore.SetInstance(new ConfigurationStore()); + + _configService = new ConfigurationService(_vehicleService, _toolService, _settingsService, ConfigurationStore.Instance); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_tempRoot, recursive: true); } catch { } + } + + // ---- Geometry: the tool hangs on the vehicle's hitch ---- + + private static ToolPositionService Placed(ConfigurationStore store) + { + var service = new ToolPositionService(store); + service.Update(new Vec3(0, 0, 0), 0); // heading 0 = +North + return service; + } + + // #547's case: the tractor's 3-point 1.2 m behind the axle, the tool's working point 0.8 m behind it. + [Test] + public void A_rear_tool_sits_at_the_tractors_hitch_plus_its_own_distance() + { + var store = ConfigurationStore.Instance; + store.Tool.SetToolType("rear"); + store.Vehicle.ThreePointHitchLength = 1.2; + store.Tool.WorkingPointFromHitch = 0.8; + store.Vehicle.HitchLength = 99.0; // the drawbar: not a rear-fixed tool's + + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(-2.0).Within(1e-9)); + + // Another tractor: only the vehicle's hitch changes, and the tool moves with it. + store.Vehicle.ThreePointHitchLength = 1.5; + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(-2.3).Within(1e-9)); + } + + [Test] + public void A_front_tool_sits_ahead_at_the_front_hitch_plus_its_own_distance() + { + var store = ConfigurationStore.Instance; + store.Tool.SetToolType("front"); + store.Vehicle.FrontHitchLength = 1.5; + store.Tool.WorkingPointFromHitch = 0.5; + store.Vehicle.ThreePointHitchLength = 99.0; + + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(2.0).Within(1e-9)); + } + + [Test] + public void A_working_point_on_the_tractors_side_of_the_hitch_is_a_negative_distance() + { + var store = ConfigurationStore.Instance; + store.Tool.SetToolType("rear"); + store.Vehicle.ThreePointHitchLength = 1.2; + store.Tool.WorkingPointFromHitch = -0.3; + + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(-0.9).Within(1e-9)); + } + + [TestCase("trailing")] + [TestCase("tbt")] + public void A_trailed_tool_hangs_on_the_drawbar(string type) + { + var store = ConfigurationStore.Instance; + store.Tool.SetToolType(type); + store.Vehicle.HitchLength = 3.0; + store.Vehicle.ThreePointHitchLength = 99.0; + store.Tool.WorkingPointFromHitch = 99.0; + + Assert.That(Placed(store).HitchPosition.Northing, Is.EqualTo(-3.0).Within(1e-9)); + } + + // ---- Format 2 → 3: the tool stays where it was ---- + + private void WriteV2Vehicle(string name, string vehicleJson) => + File.WriteAllText(Path.Combine(_vehiclesDir, name + ".json"), + "{ \"formatVersion\": 2, \"vehicle\": { \"wheelbase\": 2.5" + vehicleJson + " } }"); + + private void WriteV2Tool(string name, string type, double axleToWorkingPoint) => + File.WriteAllText(Path.Combine(_toolsDir, name + ".json"), + "{ \"formatVersion\": 2, \"tool\": { \"width\": 6, \"hitchLength\": " + + axleToWorkingPoint.ToString(System.Globalization.CultureInfo.InvariantCulture) + + ", \"isToolRearFixed\": " + (type == "rear" ? "true" : "false") + + ", \"isToolFrontFixed\": " + (type == "front" ? "true" : "false") + + ", \"isToolTrailing\": " + (type == "trailing" ? "true" : "false") + " } }"); + + [Test] + public void A_format_2_rear_tool_keeps_its_place_and_both_files_are_saved_as_format_3() + { + WriteV2Vehicle("Rig", ", \"hitchLength\": 1.1"); + WriteV2Tool("Rig", "rear", 2.0); + + Assert.That(_configService.LoadProfiles("Rig", "Rig"), Is.True); + var store = ConfigurationStore.Instance; + Assert.Multiple(() => + { + Assert.That(store.Vehicle.HitchLength, Is.EqualTo(1.1).Within(1e-9), "the drawbar as it was"); + Assert.That(store.Vehicle.ThreePointHitchLength, Is.EqualTo(1.1).Within(1e-9), "the 3-point starts at the one hitch"); + Assert.That(store.Tool.WorkingPointFromHitch, Is.EqualTo(0.9).Within(1e-9)); + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(-2.0).Within(1e-9), "where format 2 had it"); + Assert.That(store.ProfileMigrationNote, Does.Contain("0.90 m")); + Assert.That(HitchMigration.FormatVersionOf(Path.Combine(_vehiclesDir, "Rig.json")), Is.EqualTo(3), "saved at once"); + Assert.That(HitchMigration.FormatVersionOf(Path.Combine(_toolsDir, "Rig.json")), Is.EqualTo(3), "saved at once"); + }); + + // Read again (format 3 now): the same place, nothing converted. + ConfigurationStore.SetInstance(new ConfigurationStore()); + var again = new ConfigurationService(_vehicleService, _toolService, _settingsService, ConfigurationStore.Instance); + Assert.That(again.LoadProfiles("Rig", "Rig"), Is.True); + Assert.Multiple(() => + { + Assert.That(ConfigurationStore.Instance.ProfileMigrationNote, Is.Null); + Assert.That(Placed(ConfigurationStore.Instance).ToolPosition.Northing, Is.EqualTo(-2.0).Within(1e-9)); + }); + } + + [Test] + public void A_format_2_front_tool_gives_the_vehicle_its_front_hitch() + { + WriteV2Vehicle("Rig", ", \"hitchLength\": 1.1"); + WriteV2Tool("Rig", "front", 3.0); + + Assert.That(_configService.LoadProfiles("Rig", "Rig"), Is.True); + var store = ConfigurationStore.Instance; + Assert.Multiple(() => + { + Assert.That(store.Vehicle.FrontHitchLength, Is.EqualTo(3.0).Within(1e-9)); + Assert.That(store.Tool.WorkingPointFromHitch, Is.EqualTo(0.0).Within(1e-9)); + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(3.0).Within(1e-9), "where format 2 had it"); + Assert.That(store.ProfileMigrationNote, Does.Contain("front hitch")); + }); + } + + [Test] + public void A_vehicle_file_from_before_the_hitch_moved_takes_it_from_the_tool() + { + WriteV2Vehicle("Rig", ""); + WriteV2Tool("Rig", "trailing", 2.3); + + Assert.That(_configService.LoadProfiles("Rig", "Rig"), Is.True); + var store = ConfigurationStore.Instance; + Assert.Multiple(() => + { + Assert.That(store.Vehicle.HitchLength, Is.EqualTo(2.3).Within(1e-9)); + Assert.That(store.Vehicle.ThreePointHitchLength, Is.EqualTo(2.3).Within(1e-9)); + Assert.That(Placed(store).HitchPosition.Northing, Is.EqualTo(-2.3).Within(1e-9)); + }); + } + + [Test] + public void Format_3_round_trips_and_still_tells_an_older_build_where_the_tool_is() + { + var store = ConfigurationStore.Instance; + store.Tool.SetToolType("rear"); + store.Vehicle.HitchLength = 4.4; + store.Vehicle.ThreePointHitchLength = 1.2; + store.Vehicle.FrontHitchLength = 2.2; + store.Tool.WorkingPointFromHitch = 0.8; + _configService.SaveProfiles("Rig", "Rig"); + + var reload = new ConfigurationStore(); + Assert.That(VehicleProfileJsonService.Load(_vehiclesDir, "Rig", reload), Is.True); + Assert.That(ToolProfileJsonService.Load(_toolsDir, "Rig", reload), Is.True); + Assert.Multiple(() => + { + Assert.That(reload.Vehicle.HitchLength, Is.EqualTo(4.4).Within(1e-9)); + Assert.That(reload.Vehicle.ThreePointHitchLength, Is.EqualTo(1.2).Within(1e-9)); + Assert.That(reload.Vehicle.FrontHitchLength, Is.EqualTo(2.2).Within(1e-9)); + Assert.That(reload.Tool.WorkingPointFromHitch, Is.EqualTo(0.8).Within(1e-9)); + Assert.That(reload.ProfileMigrationNote, Is.Null); + }); + // An older build reads format 2's hitchLength: the axle → working point with this vehicle. + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(Path.Combine(_toolsDir, "Rig.json"))); + Assert.That(doc.RootElement.GetProperty("tool").GetProperty("hitchLength").GetDouble(), Is.EqualTo(2.0).Within(1e-9)); + } + + [Test] + public void A_profile_from_a_newer_build_is_not_overwritten() + { + var path = Path.Combine(_toolsDir, "Rig.json"); + const string newer = "{ \"formatVersion\": 4, \"tool\": { \"width\": 6, \"somethingNew\": 1 } }"; + File.WriteAllText(path, newer); + ToolProfileJsonService.Save(_toolsDir, "Rig", ConfigurationStore.Instance); + Assert.That(File.ReadAllText(path), Is.EqualTo(newer)); + } + + // AgOpenGPS: one hitch length, the fixed tool's working point at it (CTool). The vehicle's hitches + // take it and the tool's distance is 0: the tool where AgOpenGPS has it. + [TestCase(false, -2.0)] + [TestCase(true, 2.0)] + public void An_AgOpenGPS_profile_puts_the_tool_where_AgOpenGPS_has_it(bool front, double y) + { + File.WriteAllText(Path.Combine(_vehiclesDir, "Legacy.XML"), + "" + + "" + + "-2.0" + + "" + (front ? "True" : "False") + "" + + "" + (front ? "False" : "True") + "" + + ""); + + var store = new ConfigurationStore(); + Assert.That(_vehicleService.Load("Legacy", store), Is.True); + Assert.Multiple(() => + { + Assert.That(store.Vehicle.HitchLength, Is.EqualTo(2.0).Within(1e-9)); + Assert.That(store.Vehicle.ThreePointHitchLength, Is.EqualTo(2.0).Within(1e-9)); + Assert.That(store.Vehicle.FrontHitchLength, Is.EqualTo(front ? 2.0 : 0.0).Within(1e-9)); + Assert.That(store.Tool.WorkingPointFromHitch, Is.EqualTo(0.0).Within(1e-9)); + Assert.That(Placed(store).ToolPosition.Northing, Is.EqualTo(y).Within(1e-9)); + }); + } + + private sealed class TestVehicleProfileService : VehicleProfileService + { + public TestVehicleProfileService(string dir) + : base(NullLogger.Instance, dir) { } + } + + private sealed class TestToolProfileService : ToolProfileService + { + public TestToolProfileService(string dir) + : base(NullLogger.Instance, dir) { } + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/HitchSplitTests.cs b/Tests/AgOpenWeb.Services.Tests/HitchSplitTests.cs deleted file mode 100644 index 02777aa5b..000000000 --- a/Tests/AgOpenWeb.Services.Tests/HitchSplitTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -// AgOpenWeb -// Copyright (C) 2024-2026 AgOpenWeb Contributors -// SPDX-License-Identifier: Apache-2.0 - -using System; -using System.IO; -using AgOpenWeb.Models; -using AgOpenWeb.Models.Base; -using AgOpenWeb.Models.Configuration; -using AgOpenWeb.Services; -using AgOpenWeb.Services.Interfaces; -using AgOpenWeb.Services.Profile; -using AgOpenWeb.Services.Tool; -using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; - -namespace AgOpenWeb.Services.Tests; - -/// -/// Vehicle/Tool config split: the overloaded Tool.HitchLength was split into -/// Vehicle.HitchLength (#1, rear axle → tractor hitch pin, used by trailing/TBT) -/// and Tool.HitchLength (#2/#3, axle → rigid working center, used by fixed tools). -/// These tests pin the two halves: the geometry branch by tool type, and the -/// persistence/migration that seeds Vehicle.HitchLength on pre-split profiles. -/// -[TestFixture] -[NonParallelizable] // ConfigurationStore singleton + per-test temp dirs. -public class HitchSplitTests -{ - private string _tempRoot = null!; - private string _vehiclesDir = null!; - private string _toolsDir = null!; - private TestVehicleProfileService _vehicleService = null!; - private TestToolProfileService _toolService = null!; - private ISettingsService _settingsService = null!; - private AppSettings _settings = null!; - private ConfigurationService _configService = null!; - - [SetUp] - public void SetUp() - { - _tempRoot = Path.Combine(Path.GetTempPath(), "AgOpenWeb-HitchSplit-" + Guid.NewGuid().ToString("N")); - _vehiclesDir = Path.Combine(_tempRoot, "Vehicles"); - _toolsDir = Path.Combine(_tempRoot, "Tools"); - Directory.CreateDirectory(_vehiclesDir); - Directory.CreateDirectory(_toolsDir); - - _vehicleService = new TestVehicleProfileService(_vehiclesDir); - _toolService = new TestToolProfileService(_toolsDir); - - _settings = new AppSettings(); - _settingsService = Substitute.For(); - _settingsService.Settings.Returns(_settings); - - ConfigurationStore.SetInstance(new ConfigurationStore()); - - _configService = new ConfigurationService(_vehicleService, _toolService, _settingsService, ConfigurationStore.Instance); - } - - [TearDown] - public void TearDown() - { - try { Directory.Delete(_tempRoot, recursive: true); } catch { } - } - - // ---- Geometry: the hitch reference must branch by tool type ---- - - [Test] - public void Geometry_RigidTool_UsesToolHitchLength_IgnoresVehicle() - { - var store = ConfigurationStore.Instance; - store.Tool.IsToolRearFixed = true; - store.Tool.IsToolTrailing = false; - store.Tool.IsToolTBT = false; - store.Tool.IsToolFrontFixed = false; - store.Tool.Offset = 0; - store.Tool.HitchLength = 3.0; // rigid working center — should drive the hitch - store.Vehicle.HitchLength = 99.0; // vehicle pin — must be ignored for rigid - - var service = new ToolPositionService(store); - service.Update(new Vec3(0, 0, 0), 0); // heading 0 = +North - - // Rear tool → hitch is behind the pivot by Tool.HitchLength. - Assert.That(service.HitchPosition.Northing, Is.EqualTo(-3.0).Within(1e-6)); - Assert.That(service.HitchPosition.Easting, Is.EqualTo(0.0).Within(1e-6)); - } - - [Test] - public void Geometry_TrailingTool_UsesVehicleHitchLength_IgnoresTool() - { - var store = ConfigurationStore.Instance; - store.Tool.IsToolRearFixed = false; - store.Tool.IsToolTrailing = true; - store.Tool.IsToolTBT = false; - store.Tool.IsToolFrontFixed = false; - store.Tool.Offset = 0; - store.Tool.HitchLength = 99.0; // must be ignored for trailing - store.Vehicle.HitchLength = 3.0; // tractor hitch pin — should drive the hitch - - var service = new ToolPositionService(store); - service.Update(new Vec3(0, 0, 0), 0); - - // Trailing → hitch (the tractor pin) is behind the pivot by Vehicle.HitchLength. - Assert.That(service.HitchPosition.Northing, Is.EqualTo(-3.0).Within(1e-6)); - Assert.That(service.HitchPosition.Easting, Is.EqualTo(0.0).Within(1e-6)); - } - - // ---- Persistence + migration ---- - - [Test] - public void Migration_PreSplitVehicleFile_SeedsVehicleHitchFromTool() - { - // A pre-split v2 vehicle file with NO hitchLength, paired with a tool - // file that still carries the legacy hitch under Tool.HitchLength. - File.WriteAllText(Path.Combine(_vehiclesDir, "Combo.json"), - "{\n \"formatVersion\": 2,\n \"vehicle\": { \"wheelbase\": 2.5 }\n}"); - - var toolSource = new ConfigurationStore(); - toolSource.Tool.HitchLength = 2.3; - ToolProfileJsonService.Save(_toolsDir, "Combo", toolSource); - - Assert.That(_configService.LoadProfiles("Combo", "Combo"), Is.True); - Assert.That(ConfigurationStore.Instance.Vehicle.HitchLength, Is.EqualTo(2.3).Within(1e-6), - "Pre-split vehicle file (no hitchLength) should be seeded from the legacy Tool.HitchLength"); - } - - [Test] - public void Migration_VehicleFileWithHitch_NotClobberedByTool() - { - File.WriteAllText(Path.Combine(_vehiclesDir, "Combo.json"), - "{\n \"formatVersion\": 2,\n \"vehicle\": { \"wheelbase\": 2.5, \"hitchLength\": 1.1 }\n}"); - - var toolSource = new ConfigurationStore(); - toolSource.Tool.HitchLength = 9.9; - ToolProfileJsonService.Save(_toolsDir, "Combo", toolSource); - - Assert.That(_configService.LoadProfiles("Combo", "Combo"), Is.True); - Assert.That(ConfigurationStore.Instance.Vehicle.HitchLength, Is.EqualTo(1.1).Within(1e-6), - "An explicit vehicle hitchLength must win — no migration overwrite"); - } - - [Test] - public void RoundTrip_VehicleAndToolHitch_PersistIndependently() - { - var store = ConfigurationStore.Instance; - store.Vehicle.HitchLength = 4.4; - store.Tool.HitchLength = 6.6; - - _configService.SaveProfiles("Rig", "Rig"); - - var reload = new ConfigurationStore(); - Assert.That(VehicleProfileJsonService.Load(_vehiclesDir, "Rig", reload), Is.True); - Assert.That(ToolProfileJsonService.Load(_toolsDir, "Rig", reload), Is.True); - - Assert.That(reload.Vehicle.HitchLength, Is.EqualTo(4.4).Within(1e-6)); - Assert.That(reload.Tool.HitchLength, Is.EqualTo(6.6).Within(1e-6)); - } - - [Test] - public void XmlImport_VehicleHitchKey_PopulatesBothVehicleAndTool() - { - // AOG's single setVehicle_hitchLength is the tractor pin (vehicle) and - // doubles as the rigid working center (tool); import populates both. - File.WriteAllText(Path.Combine(_vehiclesDir, "Legacy.XML"), - "" + - "" + - "2.0" + - ""); - - var store = new ConfigurationStore(); - Assert.That(_vehicleService.Load("Legacy", store), Is.True); - Assert.That(store.Vehicle.HitchLength, Is.EqualTo(2.0).Within(1e-6)); - Assert.That(store.Tool.HitchLength, Is.EqualTo(2.0).Within(1e-6)); - } - - private sealed class TestVehicleProfileService : VehicleProfileService - { - public TestVehicleProfileService(string dir) - : base(NullLogger.Instance, dir) { } - } - - private sealed class TestToolProfileService : ToolProfileService - { - public TestToolProfileService(string dir) - : base(NullLogger.Instance, dir) { } - } -} diff --git a/Tests/AgOpenWeb.Services.Tests/JobFormatVersionTests.cs b/Tests/AgOpenWeb.Services.Tests/JobFormatVersionTests.cs new file mode 100644 index 000000000..48487e680 --- /dev/null +++ b/Tests/AgOpenWeb.Services.Tests/JobFormatVersionTests.cs @@ -0,0 +1,148 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using AgOpenWeb.Models.Base; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Job; +using AgOpenWeb.Services.Coverage; +using AgOpenWeb.Services.Fields; + +namespace AgOpenWeb.Services.Tests; + +/// +/// job.json and the coverage manifest are versioned like field.geojson (Docs/FIELD_FILE_FORMATS.md): +/// what a newer build adds survives this build's saves, and a job in a newer format is listed +/// but never written, its coverage neither read nor replaced. +/// +[TestFixture, NonParallelizable] // ConfigurationStore is a singleton +public class JobFormatVersionTests +{ + private string _fieldDir = null!; + private ConfigurationStore _store = null!; + private const string Task = "2026-11-13_Spraying"; + + [SetUp] + public void SetUp() + { + _fieldDir = Path.Combine(Path.GetTempPath(), $"agopenweb_jobfmt_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_fieldDir); + _store = new ConfigurationStore(); + ConfigurationStore.SetInstance(_store); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_fieldDir, true); } catch { } + } + + private string JobDir => JobJsonService.JobDirectory(_fieldDir, Task); + private string JobFile => JobJsonService.JobFilePath(_fieldDir, Task); + private string ManifestFile => Path.Combine(JobDir, "coverage", "manifest.json"); + + private Job NewJob() => new() { FieldName = Path.GetFileName(_fieldDir), TaskName = Task, WorkType = "Spraying" }; + + private void EditJson(string path, Action edit) + { + var o = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + edit(o); + File.WriteAllText(path, o.ToJsonString()); + } + + /// Every file under the job, with a hash of its content. + private Dictionary Snapshot() => + Directory.GetFiles(JobDir, "*", SearchOption.AllDirectories).ToDictionary( + f => Path.GetRelativePath(JobDir, f), + f => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f)))); + + private CoverageMapService Coverage() + { + var svc = new CoverageMapService(_store); + svc.SetFieldBounds(-100, 100, -100, 100); + return svc; + } + + private static void PaintStrip(CoverageMapService svc, double e0, double e1, double n0, double n1) + { + svc.StartMapping(0, new Vec2(e0, n0), new Vec2(e1, n0)); + for (double n = n0 + 1; n <= n1; n += 1) + svc.AddCoveragePoint(0, new Vec2(e0, n), new Vec2(e1, n)); + svc.StopMapping(0); + } + + [Test] + public void A_newer_builds_job_members_survive_this_builds_save() + { + JobJsonService.Save(NewJob(), _fieldDir); + EditJson(JobFile, o => o["implementId"] = "boom-24"); + + var job = JobJsonService.Load(_fieldDir, Task)!; + job.Notes = "windy"; + JobJsonService.Save(job, _fieldDir); + + var saved = JsonNode.Parse(File.ReadAllText(JobFile))!.AsObject(); + Assert.Multiple(() => + { + Assert.That(saved["implementId"]?.GetValue(), Is.EqualTo("boom-24")); + Assert.That(saved["notes"]?.GetValue(), Is.EqualTo("windy")); + }); + } + + [Test] + public void A_newer_job_is_listed_but_never_written() + { + JobJsonService.Save(NewJob(), _fieldDir); + EditJson(JobFile, o => { o["schemaVersion"] = JobJsonService.CurrentSchemaVersion + 1; o["status"] = "Paused"; }); + var bytes = File.ReadAllText(JobFile); + + var listed = JobJsonService.LoadAll(_fieldDir); + Assert.That(listed.Select(j => j.TaskName), Is.EqualTo(new[] { Task }), "a status this build doesn't know doesn't hide the job"); + Assert.That(JobJsonService.IsNewerFormat(_fieldDir, Task), Is.True); + + var job = listed[0]; + job.Status = JobStatus.Done; + JobJsonService.Save(job, _fieldDir); + Assert.That(File.ReadAllText(JobFile), Is.EqualTo(bytes)); + } + + [Test] + public void Coverage_in_a_newer_format_is_neither_read_nor_replaced() + { + JobJsonService.Save(NewJob(), _fieldDir); + var first = Coverage(); + PaintStrip(first, 0, 10, 0, 20); + first.SaveToFile(JobDir); + Assume.That(File.Exists(ManifestFile)); + EditJson(ManifestFile, o => o["SchemaVersion"] = CoverageTileStore.CurrentSchemaVersion + 1); + File.WriteAllText(Path.Combine(JobDir, "coverage", "d", "newer.tile"), "a tile this build doesn't know"); + var before = Snapshot(); + + Assert.That(JobJsonService.IsNewerFormat(_fieldDir, Task), Is.True, "the job counts as newer by its coverage"); + + var svc = Coverage(); + svc.LoadFromFile(JobDir); + Assert.That(svc.TotalWorkedArea, Is.EqualTo(0), "its coverage isn't read"); + + PaintStrip(svc, 20, 30, 0, 20); + svc.SaveToFile(JobDir); + + Assert.That(Snapshot(), Is.EqualTo(before), "nothing was set aside, replaced or deleted"); + } + + [Test] + public void The_current_versions_are_not_newer() + { + JobJsonService.Save(NewJob(), _fieldDir); + var svc = Coverage(); + PaintStrip(svc, 0, 10, 0, 20); + svc.SaveToFile(JobDir); + + Assert.That(JobJsonService.IsNewerFormat(_fieldDir, Task), Is.False); + var reopened = Coverage(); + reopened.LoadFromFile(JobDir); + Assert.That(reopened.TotalWorkedArea, Is.GreaterThan(0)); + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/JobRigTests.cs b/Tests/AgOpenWeb.Services.Tests/JobRigTests.cs new file mode 100644 index 000000000..c8ce9800a --- /dev/null +++ b/Tests/AgOpenWeb.Services.Tests/JobRigTests.cs @@ -0,0 +1,176 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Nodes; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Job; +using AgOpenWeb.Models.Tool; +using AgOpenWeb.Services.Fields; + +namespace AgOpenWeb.Services.Tests; + +/// +/// A job keeps the machine it was worked with (#547): the vehicle's hitch points and the chain of +/// tools on them, each measured from the connector it hangs on, in job.json's rig. +/// +[TestFixture] +public class JobRigTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), $"aow_jobrig_{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(_root, "north40")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } + + private static ConfigurationStore Store(string type) + { + var s = new ConfigurationStore { ActiveVehicleProfileName = "Fendt 312", ActiveToolProfileName = "Drill 3 m" }; + s.Vehicle.HitchLength = 1.9; + s.Vehicle.ThreePointHitchLength = 1.2; + s.Vehicle.FrontHitchLength = 2.6; + s.Vehicle.HitchType = 3; + s.Tool.SetToolType(type); + s.Tool.WorkingPointFromHitch = 0.8; + s.Tool.TrailingHitchLength = 4.0; + s.Tool.TankTrailingHitchLength = 3.0; + s.Tool.TrailingToolToPivotLength = 0.5; + s.Tool.Offset = 0.25; + s.Tool.Length = 2.0; + s.Tool.HitchType = 5; + s.NumSections = 3; + s.Tool.SetSectionWidth(0, 100); + s.Tool.SetSectionWidth(1, 150); + s.Tool.SetSectionWidth(2, 50); + return s; + } + + [TestCase("rear", RigConnector.RearThreePoint, ToolMount.RearFixed)] + [TestCase("front", RigConnector.FrontThreePoint, ToolMount.FrontFixed)] + [TestCase("trailing", RigConnector.Drawbar, ToolMount.Trailing)] + [TestCase("tbt", RigConnector.Drawbar, ToolMount.TBT)] + public void The_copy_is_the_loaded_vehicle_and_tool(string type, RigConnector connector, ToolMount mount) + { + var rig = JobRig.From(Store(type)); + var t = rig.Tools.Single(); + bool rigid = mount is ToolMount.FrontFixed or ToolMount.RearFixed; + Assert.Multiple(() => + { + Assert.That(rig.Vehicle.ProfileName, Is.EqualTo("Fendt 312")); + Assert.That(rig.Vehicle.Type, Is.EqualTo("Tractor")); + Assert.That(rig.Vehicle.HitchType, Is.EqualTo(3)); + Assert.That(rig.Vehicle.DrawbarHitch, Is.EqualTo(1.9)); + Assert.That(rig.Vehicle.RearThreePointHitch, Is.EqualTo(1.2)); + Assert.That(rig.Vehicle.FrontThreePointHitch, Is.EqualTo(2.6)); + Assert.That(t.ProfileName, Is.EqualTo("Drill 3 m")); + Assert.That(t.Connector, Is.EqualTo(connector)); + Assert.That(t.Mount, Is.EqualTo(mount)); + Assert.That(t.HitchType, Is.EqualTo(5)); + // Only the distances the tool type uses: the others are the profile's leftovers. + Assert.That(t.WorkingPointFromHitch, Is.EqualTo(rigid ? 0.8 : 0)); + Assert.That(t.TrailingHitchLength, Is.EqualTo(rigid ? 0 : 4.0)); + Assert.That(t.TankTrailingHitchLength, Is.EqualTo(mount == ToolMount.TBT ? 3.0 : 0)); + Assert.That(t.TrailingToolToPivotLength, Is.EqualTo(rigid ? 0 : 0.5)); + Assert.That(t.Offset, Is.EqualTo(0.25)); + Assert.That(t.Length, Is.EqualTo(2.0)); + Assert.That(t.SectionWidths, Is.EqualTo(new[] { 1.0, 1.5, 0.5 })); + Assert.That(t.Width, Is.EqualTo(3.0).Within(1e-9)); + Assert.That(t.RearConnectorFromHitch, Is.Null); + }); + } + + [Test] + public void A_chain_of_tools_is_read_back_as_written() + { + var dir = Path.Combine(_root, "north40"); + var rig = JobRig.From(Store("rear")); + rig.Tools[0].RearConnectorFromHitch = 2.4; + rig.Tools.Add(new RigTool + { + ProfileName = "Roller", Connector = RigConnector.PreviousTool, Mount = ToolMount.Trailing, + TrailingHitchLength = 1.5, Width = 3, SectionWidths = { 3 }, + }); + JobJsonService.Save(new Job { FieldName = "north40", TaskName = "t1", Rig = rig }, dir); + + var back = JobJsonService.Load(dir, "t1")!.Rig; + Assert.That(back, Is.Not.Null); + Assert.That(back!.SameAs(rig), Is.True); + + // The names another program reads (Docs/FIELD_FILE_FORMATS.md). + var o = JsonNode.Parse(File.ReadAllText(JobJsonService.JobFilePath(dir, "t1")))!; + Assert.Multiple(() => + { + Assert.That((int)o["schemaVersion"]!, Is.EqualTo(1), "a new member does not change the version"); + Assert.That((double)o["rig"]!["vehicle"]!["rearThreePointHitch"]!, Is.EqualTo(1.2)); + Assert.That((string)o["rig"]!["tools"]![0]!["connector"]!, Is.EqualTo("RearThreePoint")); + Assert.That((string)o["rig"]!["tools"]![0]!["mount"]!, Is.EqualTo("RearFixed")); + Assert.That((double)o["rig"]!["tools"]![0]!["workingPointFromHitch"]!, Is.EqualTo(0.8)); + Assert.That((string)o["rig"]!["tools"]![1]!["connector"]!, Is.EqualTo("PreviousTool")); + Assert.That(o["rig"]!["tools"]![1]!["rearConnectorFromHitch"], Is.Null, "left out when the tool has none"); + }); + } + + [Test] + public void A_job_saved_before_has_no_machine_and_gets_none_written() + { + var dir = Path.Combine(_root, "north40"); + JobJsonService.Save(new Job { FieldName = "north40", TaskName = "old" }, dir); + Assert.That(JobJsonService.Load(dir, "old")!.Rig, Is.Null); + Assert.That(File.ReadAllText(JobJsonService.JobFilePath(dir, "old")), Does.Not.Contain("\"rig\"")); + } + + [Test] + public void The_active_job_keeps_the_machine_and_writes_only_a_change() + { + var svc = new JobService(() => _root); + var job = svc.CreateJob("north40", "drilling", ""); + var path = JobJsonService.JobFilePath(Path.Combine(_root, "north40"), job.TaskName); + var store = Store("rear"); + + svc.RecordRig(JobRig.From(store)); + Assert.That(JobJsonService.Load(Path.Combine(_root, "north40"), job.TaskName)!.Rig!.Tools[0].WorkingPointFromHitch, Is.EqualTo(0.8)); + + // The same machine again: no write (the file taken away stays away). + File.Delete(path); + svc.RecordRig(JobRig.From(store)); + Assert.That(File.Exists(path), Is.False, "the same machine is not written again"); + + // The operator moves the tractor's 3-point hitch: the job's copy follows. + store.Vehicle.ThreePointHitchLength = 1.4; + svc.RecordRig(JobRig.From(store)); + Assert.That(JobJsonService.Load(Path.Combine(_root, "north40"), job.TaskName)!.Rig!.Vehicle.RearThreePointHitch, Is.EqualTo(1.4)); + } + + [Test] + public void No_active_job_nothing_is_written() + { + var svc = new JobService(() => _root); + Assert.DoesNotThrow(() => svc.RecordRig(JobRig.From(Store("rear")))); + Assert.That(Directory.Exists(JobJsonService.JobsRoot(Path.Combine(_root, "north40"))), Is.False); + } + + [Test] + public void A_newer_builds_job_is_not_written() + { + var dir = Path.Combine(_root, "north40"); + var svc = new JobService(() => _root); + var job = svc.CreateJob("north40", "drilling", ""); + var path = JobJsonService.JobFilePath(dir, job.TaskName); + var o = JsonNode.Parse(File.ReadAllText(path))!; + o["schemaVersion"] = JobJsonService.CurrentSchemaVersion + 1; + File.WriteAllText(path, o.ToJsonString()); + var before = File.ReadAllText(path); + + svc.RecordRig(JobRig.From(Store("rear"))); + Assert.That(File.ReadAllText(path), Is.EqualTo(before)); + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/LookAheadSlitTests.cs b/Tests/AgOpenWeb.Services.Tests/LookAheadSlitTests.cs index b954ebce0..3e3ab9d88 100644 --- a/Tests/AgOpenWeb.Services.Tests/LookAheadSlitTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/LookAheadSlitTests.cs @@ -73,7 +73,9 @@ private void SetUpPipeline(int numSections, double totalToolWidth, double sectionWidthCm = (totalToolWidth / numSections) * 100.0; for (int i = 0; i < numSections; i++) config.Tool.SetSectionWidth(i, (int)sectionWidthCm); - config.Tool.HitchLength = 0; + // A fixed tool's working point 0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 0; config.Tool.IsToolRearFixed = true; config.Tool.IsToolTrailing = false; diff --git a/Tests/AgOpenWeb.Services.Tests/OffsetFixTests.cs b/Tests/AgOpenWeb.Services.Tests/OffsetFixTests.cs index defdd1c4f..e160d7ada 100644 --- a/Tests/AgOpenWeb.Services.Tests/OffsetFixTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/OffsetFixTests.cs @@ -130,7 +130,9 @@ public void ToolPositionService_FollowsVehicle() // Configure a rear-fixed hitch at 2m behind var config = AgOpenWeb.Models.Configuration.ConfigurationStore.Instance; - config.Tool.HitchLength = 2.0; + // A fixed tool's working point 2.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(2.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.IsToolRearFixed = true; // Vehicle at (100, 200) heading north (0 radians) @@ -156,7 +158,9 @@ public void ToolPositionService_ResetAfterLargeJump_SnapsImmediately() { var config = AgOpenWeb.Models.Configuration.ConfigurationStore.Instance; config.Vehicle.HitchLength = 2.0; // trailing pin distance (axle -> tractor hitch pin) - config.Tool.HitchLength = 2.0; + // A fixed tool's working point 2.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(2.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 3.0; config.Tool.IsToolTrailing = true; config.Tool.IsToolRearFixed = false; @@ -202,7 +206,9 @@ public void ToolPositionService_ResetPlacesHitchBehindVehicle() // Bug: ResetTrailingState used +HitchLength (ahead) instead of -HitchLength (behind) var config = AgOpenWeb.Models.Configuration.ConfigurationStore.Instance; config.Vehicle.HitchLength = 5.0; // trailing pin distance (axle -> tractor hitch pin) - config.Tool.HitchLength = 5.0; + // A fixed tool's working point 5.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(5.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 15.0; config.Tool.IsToolTrailing = true; config.Tool.IsToolRearFixed = false; @@ -234,7 +240,9 @@ public void ToolPositionService_ResetThenUpdateProducesConsistentPositions() // After reset + one Update frame, tool should be near vehicle var config = AgOpenWeb.Models.Configuration.ConfigurationStore.Instance; config.Vehicle.HitchLength = 5.0; // trailing pin distance (axle -> tractor hitch pin) - config.Tool.HitchLength = 5.0; + // A fixed tool's working point 5.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(5.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 15.0; config.Tool.IsToolTrailing = true; config.Tool.IsToolRearFixed = false; @@ -272,7 +280,9 @@ public void ToolPositionService_TrailingStillWorksAtNormalSpeed() // causing the tool to snap rigid every frame instead of trailing var config = AgOpenWeb.Models.Configuration.ConfigurationStore.Instance; config.Vehicle.HitchLength = 2.0; // trailing pin distance (axle -> tractor hitch pin) - config.Tool.HitchLength = 2.0; + // A fixed tool's working point 2.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(2.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.TrailingHitchLength = 15.0; // Large trailing distance config.Tool.IsToolTrailing = true; config.Tool.IsToolRearFixed = false; diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/ClosedPassSeamPipelineTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/ClosedPassSeamPipelineTests.cs index bd7021141..8894a80ef 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/ClosedPassSeamPipelineTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/ClosedPassSeamPipelineTests.cs @@ -26,7 +26,7 @@ namespace AgOpenWeb.Services.Tests.Pipeline; /// -/// AutoSteer at the seam of a boundary curve's lap, through the whole cycle (fork only). The lap ran +/// AutoSteer at the seam of a boundary curve's lap, through the whole cycle. The lap ran /// on 0.2 m past its seam and back (), so a /// tractor engaged a little off the line there was steered to a goal behind it and turned round. /// @@ -60,7 +60,9 @@ public void SetUp() config.Tool.IsToolRearFixed = true; config.Tool.IsToolTrailing = false; config.Tool.IsToolTBT = false; - config.Tool.HitchLength = -1.5; + // A fixed tool's working point -1.5 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(-1.5); + config.Tool.WorkingPointFromHitch = 0; config.Guidance.UTurnRadius = 8; _gpsService = new GpsService(); diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/FrontToolCurveTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/FrontToolCurveTests.cs index f52bd3434..aede82de3 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/FrontToolCurveTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/FrontToolCurveTests.cs @@ -61,7 +61,9 @@ public void SetUp() config.Tool.IsToolRearFixed = false; config.Tool.IsToolTrailing = false; config.Tool.IsToolTBT = false; - config.Tool.HitchLength = Hitch; + // A fixed tool's working point Hitch m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(Hitch); + config.Tool.WorkingPointFromHitch = 0; config.Guidance.UTurnRadius = 8; _gpsService = new GpsService(); @@ -218,7 +220,9 @@ public void A_rear_implement_is_steered_as_before() { ConfigurationStore.Instance.Tool.IsToolFrontFixed = false; ConfigurationStore.Instance.Tool.IsToolRearFixed = true; - ConfigurationStore.Instance.Tool.HitchLength = -1.5; + // A fixed tool's working point -1.5 m from the rear axle: the tractor's hitch there, the tool on it (#547). + ConfigurationStore.Instance.Vehicle.ThreePointHitchLength = ConfigurationStore.Instance.Vehicle.FrontHitchLength = System.Math.Abs(-1.5); + ConfigurationStore.Instance.Tool.WorkingPointFromHitch = 0; Open(); var (_, _, pivot) = DriveRound(LapRadius); Assert.That(pivot, Is.EqualTo(0).Within(0.08), "the pivot on the lap"); diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/HardFenceGuardPipelineTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/HardFenceGuardPipelineTests.cs index 6e32574fa..0a24b0ff7 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/HardFenceGuardPipelineTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/HardFenceGuardPipelineTests.cs @@ -65,7 +65,9 @@ public void SetUp() config.Tool.IsToolRearFixed = false; config.Tool.IsToolTrailing = false; config.Tool.IsToolTBT = false; - config.Tool.HitchLength = Hitch; + // A fixed tool's working point Hitch m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(Hitch); + config.Tool.WorkingPointFromHitch = 0; config.Guidance.UTurnRadius = 8; _gpsService = new GpsService(); diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/HeadlandToggleTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/HeadlandToggleTests.cs index 74713fa43..abe0de2ec 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/HeadlandToggleTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/HeadlandToggleTests.cs @@ -168,7 +168,9 @@ public void HeadlandDistanceHud_PastTheLineHeadingOut_KeepsTheLastDistance() /// The control loop's tool update (heading north, the tool on the pivot), then one GPS cycle. private void DriveTool(double e, double n) { - ConfigurationStore.Instance.Tool.HitchLength = 0; + // A fixed tool's working point 0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + ConfigurationStore.Instance.Vehicle.ThreePointHitchLength = ConfigurationStore.Instance.Vehicle.FrontHitchLength = System.Math.Abs(0); + ConfigurationStore.Instance.Tool.WorkingPointFromHitch = 0; _toolPosition.Update(new Vec3(e, n, 0), 0); Cycle(); } diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/LateralMoveTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/LateralMoveTests.cs index 33ddc9200..825c4da65 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/LateralMoveTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/LateralMoveTests.cs @@ -2,6 +2,7 @@ // Copyright (C) 2024-2026 AgOpenWeb Contributors // SPDX-License-Identifier: Apache-2.0 +using System; using System.Collections.Generic; using AgOpenWeb.Models; using AgOpenWeb.Models.Base; diff --git a/Tests/AgOpenWeb.Services.Tests/Pipeline/SideStepPipelineTests.cs b/Tests/AgOpenWeb.Services.Tests/Pipeline/SideStepPipelineTests.cs index a257128dd..03a61774b 100644 --- a/Tests/AgOpenWeb.Services.Tests/Pipeline/SideStepPipelineTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Pipeline/SideStepPipelineTests.cs @@ -65,7 +65,9 @@ public void SetUp() config.Tool.IsToolRearFixed = false; config.Tool.IsToolTrailing = false; config.Tool.IsToolTBT = false; - config.Tool.HitchLength = Hitch; + // A fixed tool's working point Hitch m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(Hitch); + config.Tool.WorkingPointFromHitch = 0; config.Guidance.UTurnRadius = 8; _gpsService = new GpsService(); diff --git a/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs b/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs index aedf368b7..33bde913e 100644 --- a/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs +++ b/Tests/AgOpenWeb.Services.Tests/ProfileJsonServiceV1Tests.cs @@ -80,7 +80,8 @@ public void SaveAndLoad_ToolConfig_AllProperties() _store.Tool.Width = 12.0; _store.Tool.Overlap = 0.15; _store.Tool.Offset = -0.5; - _store.Tool.HitchLength = 2.5; + _store.Vehicle.ThreePointHitchLength = 2.5; // v1 kept one hitch length (#547) + _store.Tool.WorkingPointFromHitch = 0; _store.Tool.IsToolTrailing = true; _store.Tool.IsToolTBT = false; _store.NumSections = 4; @@ -94,7 +95,8 @@ public void SaveAndLoad_ToolConfig_AllProperties() Assert.That(loadStore.Tool.Width, Is.EqualTo(12.0).Within(1e-6)); Assert.That(loadStore.Tool.Overlap, Is.EqualTo(0.15).Within(1e-6)); Assert.That(loadStore.Tool.Offset, Is.EqualTo(-0.5).Within(1e-6)); - Assert.That(loadStore.Tool.HitchLength, Is.EqualTo(2.5).Within(1e-6)); + Assert.That(loadStore.Vehicle.HitchLength, Is.EqualTo(2.5).Within(1e-6), "v1's one hitch length: the drawbar"); + Assert.That(AgOpenWeb.Models.Tool.RigGeometry.AxleToWorkingPoint(loadStore.Vehicle, loadStore.Tool), Is.EqualTo(2.5).Within(1e-6)); Assert.That(loadStore.Tool.IsToolTrailing, Is.True); Assert.That(loadStore.Tool.IsToolTBT, Is.False); Assert.That(loadStore.NumSections, Is.EqualTo(4)); diff --git a/Tests/AgOpenWeb.Services.Tests/ProfileMigrationTests.cs b/Tests/AgOpenWeb.Services.Tests/ProfileMigrationTests.cs index 94cd35f7f..4d895e680 100644 --- a/Tests/AgOpenWeb.Services.Tests/ProfileMigrationTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/ProfileMigrationTests.cs @@ -108,9 +108,10 @@ public void V1Profile_OverwritesVehicleFileWithV2Schema() _configService.MigrateV1ProfilesIfNeeded(); - // The vehicle file should now have FormatVersion = 2 and no Tool block. + // The vehicle file should now be the current format (3 since the hitch moved to the vehicle, + // #547) with no Tool block. var json = File.ReadAllText(Path.Combine(_vehiclesDir, "MyTractor.json")); - Assert.That(json, Does.Contain("\"formatVersion\": 2")); + Assert.That(json, Does.Contain("\"formatVersion\": 3")); Assert.That(json, Does.Not.Contain("\"tool\":"), "v2 vehicle file must not carry the Tool block (it lives in Tools/.json)"); Assert.That(json, Does.Not.Contain("\"sections\":"), diff --git a/Tests/AgOpenWeb.Services.Tests/Tool/RigGeometryTests.cs b/Tests/AgOpenWeb.Services.Tests/Tool/RigGeometryTests.cs new file mode 100644 index 000000000..faaa999c7 --- /dev/null +++ b/Tests/AgOpenWeb.Services.Tests/Tool/RigGeometryTests.cs @@ -0,0 +1,122 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using AgOpenWeb.Models.Base; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Tool; +using AgOpenWeb.Services.Tool; +using NUnit.Framework; + +namespace AgOpenWeb.Services.Tests.Tool; + +/// +/// Where the tool hangs on the tractor is worked out in one place, +/// (phase 1 of NonTracked/HITCH_MODEL_PLAN.md: today's model, no change in where the tool is). +/// The tool position, the map's implement, the U-turn's swept path and its remembered-end key +/// each had their own copy, and the copies disagreed on which flag won when a profile set two. +/// +[TestFixture, NonParallelizable] // ConfigurationStore is a singleton. +public class RigGeometryTests +{ + // Every combination of the four tool-type flags (a profile sets them one by one). + private static IEnumerable Flags() => + Enumerable.Range(0, 16).Select(b => new TestCaseData((b & 1) != 0, (b & 2) != 0, (b & 4) != 0, (b & 8) != 0) + .SetName($"front {(b & 1) != 0}, rear {(b & 2) != 0}, trailing {(b & 4) != 0}, TBT {(b & 8) != 0}")); + + private static ToolConfig Tool(bool front, bool rear, bool trailing, bool tbt) => new() + { + IsToolFrontFixed = front, IsToolRearFixed = rear, IsToolTrailing = trailing, IsToolTBT = tbt, + WorkingPointFromHitch = 0, TrailingHitchLength = 2.5, TankTrailingHitchLength = 3.0, TrailingToolToPivotLength = 0.4, + }; + + // The tool position's hitch before RigGeometry (ToolPositionService.Update, develop 5babf3ad), + // with a fixed tool's axle → working point 1.3 m (format 2's Tool.HitchLength). In the hitch + // model (#547) that is the vehicle's 3-point and front hitch at 1.3 m, the tool at them. + private const double OldToolHitch = 1.3; + private static double OldAttachX(VehicleConfig v, ToolConfig t) + { + double d = (t.IsToolFrontFixed || t.IsToolRearFixed) ? OldToolHitch : Math.Abs(v.HitchLength); + if (t.IsToolRearFixed || t.IsToolTrailing || t.IsToolTBT) d = -d; + return d; + } + + [TestCaseSource(nameof(Flags))] + public void The_hitch_point_is_where_the_tool_position_had_it(bool front, bool rear, bool trailing, bool tbt) + { + var v = new VehicleConfig { HitchLength = 0.9, ThreePointHitchLength = OldToolHitch, FrontHitchLength = OldToolHitch }; + var t = Tool(front, rear, trailing, tbt); + if (!front && !rear && !trailing && !tbt) + { + // No flag set: the old copy put the tractor's hitch pin ahead of the axle and a fixed + // tool on it; RigGeometry makes it the default rear-fixed tool. + Assert.That(RigGeometry.Mount(t), Is.EqualTo(ToolMount.RearFixed)); + Assert.That(RigGeometry.AttachX(v, t), Is.EqualTo(-1.3)); + return; + } + bool oldRigid = front || rear; + Assert.Multiple(() => + { + Assert.That(RigGeometry.AttachX(v, t), Is.EqualTo(OldAttachX(v, t))); + Assert.That(RigGeometry.IsRigid(RigGeometry.Mount(t)), Is.EqualTo(oldRigid)); + if (!oldRigid) + Assert.That(RigGeometry.Mount(t), Is.EqualTo(tbt ? ToolMount.TBT : ToolMount.Trailing)); + // The swept path's geometry gives the same point. + Assert.That(RigGeometry.Geometry(v, t, 6).AttachX, Is.EqualTo(OldAttachX(v, t))); + }); + } + + [TestCase("front", 1.3)] + [TestCase("rear", -1.3)] + [TestCase("trailing", -0.9)] + [TestCase("tbt", -0.9)] + public void The_tool_position_service_puts_the_hitch_there(string type, double x) + { + var store = new ConfigurationStore(); + ConfigurationStore.SetInstance(store); + store.Vehicle.HitchLength = 0.9; + store.Vehicle.ThreePointHitchLength = store.Vehicle.FrontHitchLength = 1.3; + store.Tool.WorkingPointFromHitch = 0; + store.Tool.SetToolType(type); + var svc = new ToolPositionService(store); + svc.Update(new Vec3(10, 20, 0), 0); // heading north + Assert.Multiple(() => + { + Assert.That(svc.HitchPosition.Easting, Is.EqualTo(10).Within(1e-9)); + Assert.That(svc.HitchPosition.Northing, Is.EqualTo(20 + x).Within(1e-9)); + }); + } + + // No hitch branch of its own outside RigGeometry: the files that place the tool read the + // hitch lengths and the type flags only through it. + [TestCase("Shared/AgOpenWeb.Services/Tool/ToolPositionService.cs")] + [TestCase("Shared/AgOpenWeb.ViewModels/MainViewModel.cs")] + [TestCase("Shared/AgOpenWeb.Models/Tool/ImplementSweptPath.cs")] + [TestCase("Shared/AgOpenWeb.Services/YouTurn/YouTurnCreationService.Orchestration.cs")] + [TestCase("Shared/AgOpenWeb.Services/YouTurn/YouTurnStateMachine.cs")] + [TestCase("Shared/AgOpenWeb.RemoteServer/SceneProjector.cs")] + public void Places_the_tool_only_through_RigGeometry(string file) + { + var src = File.ReadAllText(Path.Combine(RepoRoot(), file)); + // The config DTOs carry the raw values to the settings panel; those lines are not geometry. + var lines = src.Split('\n').Where(l => !l.Contains("ConfigDto(") && !l.TrimStart().StartsWith("//") + && !l.TrimStart().StartsWith("///") && !l.Contains("foreach (var d in new[]")); + var bad = lines.Where(l => Regex.IsMatch(l, @"\.(HitchLength|ToolHitchLength|VehicleHitchLength)\b|IsTool(FrontFixed|RearFixed|Trailing|TBT)\b")) + .Select(l => l.Trim()).ToList(); + Assert.That(bad, Is.Empty, $"{file} works out the hitch itself; use RigGeometry"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AgOpenWeb.sln"))) + dir = dir.Parent; + Assert.That(dir, Is.Not.Null, "Could not locate repo root (AgOpenWeb.sln) from test base dir."); + return dir!.FullName; + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionMatchesAgOpenGpsTests.cs b/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionMatchesAgOpenGpsTests.cs index 0e4e4e5e9..f7f644135 100644 --- a/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionMatchesAgOpenGpsTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionMatchesAgOpenGpsTests.cs @@ -127,7 +127,9 @@ private static void Drive(string mount, double pivotDistance, double turnRadius var c = ConfigurationStore.Instance; c.Vehicle.AntennaPivot = 0; c.Vehicle.HitchLength = Hitch; - c.Tool.HitchLength = Hitch; + // A fixed tool's working point Hitch m from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = System.Math.Abs(Hitch); + c.Tool.WorkingPointFromHitch = 0; c.Tool.TankTrailingHitchLength = Tank; c.Tool.TrailingHitchLength = Trailing; c.Tool.TrailingToolToPivotLength = pivotDistance; @@ -157,7 +159,7 @@ private static void Drive(string mount, double pivotDistance, double turnRadius { // The map's implement (MainViewModel's render pull) is placed from the hitch and // the headings with TrailedToolCentre: it must land on the tool position, every step. - var (me, mn) = ToolPositionService.TrailedToolCentre(ours.HitchPosition.Easting, ours.HitchPosition.Northing, + var (me, mn) = AgOpenWeb.Models.Tool.RigGeometry.TrailedToolCentre(ours.HitchPosition.Easting, ours.HitchPosition.Northing, ours.TankPosition.Heading, ours.ToolHeading, c.Tool); double dm = Math.Sqrt(Math.Pow(me - ours.ToolPosition.Easting, 2) + Math.Pow(mn - ours.ToolPosition.Northing, 2)); if (dm > worstMap) { worstMap = dm; worstMapAt = step; } diff --git a/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionServiceConcurrencyTests.cs b/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionServiceConcurrencyTests.cs index d6484083a..06e6d3b3b 100644 --- a/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionServiceConcurrencyTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/Tool/ToolPositionServiceConcurrencyTests.cs @@ -29,7 +29,8 @@ public void SetUp() ConfigurationStore.SetInstance(new ConfigurationStore()); var tool = ConfigurationStore.Instance.Tool; tool.Width = 6; - tool.HitchLength = 0; + tool.WorkingPointFromHitch = 0; // the rigid tool at the rear axle, as before #547 + ConfigurationStore.Instance.Vehicle.ThreePointHitchLength = 0; tool.IsToolRearFixed = true; tool.IsToolTrailing = false; tool.Offset = 0; diff --git a/Tests/AgOpenWeb.Services.Tests/ToolOffsetCoverageTests.cs b/Tests/AgOpenWeb.Services.Tests/ToolOffsetCoverageTests.cs index b29eff679..aa42ae549 100644 --- a/Tests/AgOpenWeb.Services.Tests/ToolOffsetCoverageTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/ToolOffsetCoverageTests.cs @@ -29,7 +29,9 @@ public void SetUp() { ConfigurationStore.SetInstance(new ConfigurationStore()); _config = ConfigurationStore.Instance; - _config.Tool.HitchLength = 4; + // A fixed tool's working point 4 m from the rear axle: the tractor's hitch there, the tool on it (#547). + _config.Vehicle.ThreePointHitchLength = _config.Vehicle.FrontHitchLength = System.Math.Abs(4); + _config.Tool.WorkingPointFromHitch = 0; _config.Vehicle.HitchLength = 2; _config.Tool.TrailingHitchLength = 3; _config.Tool.Offset = 0.8; // tool 0.8 m right of the tractor diff --git a/Tests/AgOpenWeb.Services.Tests/TractorPathTests.cs b/Tests/AgOpenWeb.Services.Tests/TractorPathTests.cs index b53b1fdd5..03c4770ef 100644 --- a/Tests/AgOpenWeb.Services.Tests/TractorPathTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/TractorPathTests.cs @@ -414,7 +414,9 @@ private List DriveWithImplement( bool trailing = true, bool fixedRear = false) { var config = ConfigurationStore.Instance; - config.Tool.HitchLength = 3.0; + // A fixed tool's working point 3.0 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(3.0); + config.Tool.WorkingPointFromHitch = 0; config.Tool.Width = 6.0; config.Tool.IsToolTrailing = trailing; config.Tool.IsToolRearFixed = fixedRear; diff --git a/Tests/AgOpenWeb.Services.Tests/TramOffSendsNothingTests.cs b/Tests/AgOpenWeb.Services.Tests/TramOffSendsNothingTests.cs index 13f921e39..d6766bc0e 100644 --- a/Tests/AgOpenWeb.Services.Tests/TramOffSendsNothingTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/TramOffSendsNothingTests.cs @@ -49,7 +49,9 @@ public void SetUp() _config.Vehicle.TrackWidth = 1.8; _config.Tool.IsToolRearFixed = true; _config.Tool.IsToolTrailing = _config.Tool.IsToolTBT = _config.Tool.IsToolFrontFixed = false; - _config.Tool.HitchLength = HitchLength; + // A fixed tool's working point HitchLength m from the rear axle: the tractor's hitch there, the tool on it (#547). + _config.Vehicle.ThreePointHitchLength = _config.Vehicle.FrontHitchLength = System.Math.Abs(HitchLength); + _config.Tool.WorkingPointFromHitch = 0; _config.Tool.Offset = 0; _config.Tool.Width = 6; _config.NumSections = 1; diff --git a/Tests/AgOpenWeb.Services.Tests/TramTriggerAtToolTests.cs b/Tests/AgOpenWeb.Services.Tests/TramTriggerAtToolTests.cs index 47b088775..f302d1780 100644 --- a/Tests/AgOpenWeb.Services.Tests/TramTriggerAtToolTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/TramTriggerAtToolTests.cs @@ -44,7 +44,9 @@ public void SetUp() _config.Vehicle.TrackWidth = 1.8; _config.Tool.IsToolRearFixed = true; _config.Tool.IsToolTrailing = _config.Tool.IsToolTBT = _config.Tool.IsToolFrontFixed = false; - _config.Tool.HitchLength = HitchLength; + // A fixed tool's working point HitchLength m from the rear axle: the tractor's hitch there, the tool on it (#547). + _config.Vehicle.ThreePointHitchLength = _config.Vehicle.FrontHitchLength = System.Math.Abs(HitchLength); + _config.Tool.WorkingPointFromHitch = 0; _config.Tool.TrailingHitchLength = TrailingArm; _config.Tool.Offset = 0; _config.Tool.Width = 6; diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/AbHeadingLogTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/AbHeadingLogTests.cs new file mode 100644 index 000000000..d1aeeeda6 --- /dev/null +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/AbHeadingLogTests.cs @@ -0,0 +1,115 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using AgOpenWeb.Models; +using AgOpenWeb.Models.Base; +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Pipeline; +using AgOpenWeb.Services.Geometry; +using AgOpenWeb.Services.YouTurn; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +namespace AgOpenWeb.Services.Tests.YouTurn; + +/// +/// The AB line's debug heading was logged on every 30th count of the U-turn counter: in a +/// simulator bug report it was 1273 of the 2000 log lines a report keeps, so the report reached +/// back only 21 minutes. It is logged when the heading changes. +/// +[TestFixture] +[NonParallelizable] // ConfigurationStore is a singleton. +public class AbHeadingLogTests +{ + private sealed class CaptureLogger : ILogger + { + public readonly List Messages = new(); + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? ex, + Func formatter) => Messages.Add(formatter(state, ex)); + private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } + } + + private CaptureLogger _log = null!; + private YouTurnStateMachine _sm = null!; + private Boundary _boundary = null!; + private List _cultivated = null!; + + [SetUp] + public void SetUp() + { + ConfigurationStore.SetInstance(new ConfigurationStore()); + var c = ConfigurationStore.Instance; + c.Vehicle.Wheelbase = 2.5; + c.Vehicle.MaxSteerAngle = 35; + c.Tool.Overlap = 0; + c.NumSections = 1; + c.Tool.SetSectionWidth(0, 400); + c.Guidance.UTurnRadius = 4.0; + c.Guidance.UTurnExtension = 16.0; + c.Guidance.UTurnDistanceFromBoundary = 2.0; + c.Guidance.UTurnStyle = 0; + + var offset = new PolygonOffsetService(); + _log = new CaptureLogger(); + _sm = new YouTurnStateMachine( + new YouTurnCreationService(NullLogger.Instance, offset, c), + new YouTurnPathingService(NullLogger.Instance, c), + _log, c); + + var outer = new List { new(-50, -50), new(50, -50), new(50, 50), new(-50, 50) }; + _boundary = new Boundary + { + OuterBoundary = new BoundaryPolygon + { + Points = outer.Select(p => new BoundaryPoint(p.Easting, p.Northing, 0)).ToList(), + }, + }; + _cultivated = offset.CalculatePointHeadings(new List + { + new(-40, -40), new(40, -40), new(40, 40), new(-40, 40), + }); + } + + private YouTurnStateMachine.TickContext Ctx(Models.Track.Track track) => new( + // Pass 0 (x = 2) heading north, 15 m before the north end: a turn is planned. + new Position { Easting = 2, Northing = 25, Heading = 0, Speed = 2.78 }, + track, _boundary, _cultivated, + UTurnSkipRows: 0, IsSkipWorkedMode: false, + HeadlandCalculatedWidth: 10.0, HeadlandDistance: 10.0, + IsAlternateSkipMode: false); + + private int AbHeadingLines() => _log.Messages.Count(m => m.StartsWith("[YouTurn] AB Line: abHeading=", StringComparison.Ordinal)); + + [Test] + public void The_AB_heading_is_logged_once_while_it_stays_the_same() + { + var track = Models.Track.Track.FromABLine("AB", new Vec3(0, -100, 0), new Vec3(0, 100, 0)); + var guidance = new GuidanceWorkingState { HowManyPathsAway = 0, IsHeadingSameWay = true }; + var turn = new YouTurnWorkingState { IsEnabled = true }; + + // A minute at 10 Hz, as the pipeline ticks: the counter goes up before each tick. + for (int i = 0; i < 600; i++) + { + turn.YouTurnCounter++; + _sm.Tick(Ctx(track), guidance, turn); + } + Assert.That(turn.TurnPath, Is.Not.Null, "the scenario: a turn is planned"); + Assert.That(AbHeadingLines(), Is.EqualTo(1)); + + // Another line, another heading: logged again, once. + var angled = Models.Track.Track.FromABLine("AB2", new Vec3(-20, -100, 0), new Vec3(20, 100, 0)); + for (int i = 0; i < 600; i++) + { + turn.YouTurnCounter++; + _sm.Tick(Ctx(angled), guidance, turn); + } + Assert.That(AbHeadingLines(), Is.EqualTo(2)); + } +} diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/AutoTurnHardBoundaryTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/AutoTurnHardBoundaryTests.cs index 4bf1acac7..8a8d584aa 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/AutoTurnHardBoundaryTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/AutoTurnHardBoundaryTests.cs @@ -38,7 +38,9 @@ public void SetUp() // the implement, letting a 16 m boom hit the fence. c.Tool.Width = 6; c.Tool.Overlap = 0; - c.Tool.HitchLength = 4.5; // working-centre distance + // A fixed tool's working point 4.5 m from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = System.Math.Abs(4.5); + c.Tool.WorkingPointFromHitch = 0; // working-centre distance c.Tool.Length = 3.5; // overall implement length c.Tool.IsToolRearFixed = true; // 3-point mounted c.Tool.IsToolTrailing = false; @@ -88,8 +90,7 @@ private YouTurnCreationService.TurnPathResult Run(Boundary boundary) private static ToolGeometry Rig() => new( ToolMount.RearFixed, Width: 16, Offset: 0, - VehicleHitchLength: ConfigurationStore.Instance.Vehicle.HitchLength, - ToolHitchLength: 4.5, TrailingHitchLength: 0, TrailingToolToPivotLength: 0, + AttachX: -4.5, TrailingHitchLength: 0, TrailingToolToPivotLength: 0, TankTrailingHitchLength: 0, Length: 3.5); [Test] diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/LineEntryPlannerTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/LineEntryPlannerTests.cs index 02247b0fa..1c130ca95 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/LineEntryPlannerTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/LineEntryPlannerTests.cs @@ -43,7 +43,9 @@ public void SetUp() _config.NumSections = 1; _config.Tool.SetSectionWidth(0, 600); _config.Tool.Overlap = 0; - _config.Tool.HitchLength = 2; + // A fixed tool's working point 2 m from the rear axle: the tractor's hitch there, the tool on it (#547). + _config.Vehicle.ThreePointHitchLength = _config.Vehicle.FrontHitchLength = System.Math.Abs(2); + _config.Tool.WorkingPointFromHitch = 0; _config.Tool.IsToolRearFixed = true; _config.Tool.IsToolTrailing = false; _config.Tool.IsToolTBT = false; @@ -145,7 +147,9 @@ public void A_front_implement_adds_its_reach_to_the_straight() { _config.Tool.IsToolRearFixed = false; _config.Tool.IsToolFrontFixed = true; - _config.Tool.HitchLength = 3; + // A fixed tool's working point 3 m from the rear axle: the tractor's hitch there, the tool on it (#547). + _config.Vehicle.ThreePointHitchLength = _config.Vehicle.FrontHitchLength = System.Math.Abs(3); + _config.Tool.WorkingPointFromHitch = 0; _config.Guidance.LineEntryStraight = 3; var plan = Plan(-40, -146, 90); diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnHardBoundaryTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnHardBoundaryTests.cs index 4392fa00a..44548f31c 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnHardBoundaryTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnHardBoundaryTests.cs @@ -36,7 +36,9 @@ public void SetUp() config.NumSections = 1; config.Tool.SetSectionWidth(0, 1600); // 16 m via sections -> ActualToolWidth = 16 config.Tool.Overlap = 0; - config.Tool.HitchLength = 2; + // A fixed tool's working point 2 m from the rear axle: the tractor's hitch there, the tool on it (#547). + config.Vehicle.ThreePointHitchLength = config.Vehicle.FrontHitchLength = System.Math.Abs(2); + config.Tool.WorkingPointFromHitch = 0; config.Tool.IsToolRearFixed = true; config.Tool.IsToolTrailing = false; config.Tool.IsToolTBT = false; @@ -124,7 +126,9 @@ private static void EightMetreTool(bool front, double hitch) var c = ConfigurationStore.Instance; c.Tool.Width = 8; c.Tool.SetSectionWidth(0, 800); - c.Tool.HitchLength = hitch; + // A fixed tool's working point hitch m from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = System.Math.Abs(hitch); + c.Tool.WorkingPointFromHitch = 0; c.Tool.IsToolFrontFixed = front; c.Tool.IsToolRearFixed = !front; c.Guidance.UTurnRadius = 8; diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnRadiusTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnRadiusTests.cs index 2263d3373..17aa9eaed 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnRadiusTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/ManualTurnRadiusTests.cs @@ -36,7 +36,9 @@ public void SetUp() _config = ConfigurationStore.Instance; _config.NumSections = 1; _config.Tool.Overlap = 0; - _config.Tool.HitchLength = 2; + // A fixed tool's working point 2 m from the rear axle: the tractor's hitch there, the tool on it (#547). + _config.Vehicle.ThreePointHitchLength = _config.Vehicle.FrontHitchLength = System.Math.Abs(2); + _config.Tool.WorkingPointFromHitch = 0; _config.Tool.IsToolRearFixed = true; _config.Tool.IsToolTrailing = false; _config.Tool.IsToolTBT = false; diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleScenarioRig.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleScenarioRig.cs index 6f3892210..4d808d582 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleScenarioRig.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleScenarioRig.cs @@ -89,7 +89,12 @@ public ObstacleScenarioRig(ObstacleTestField.Built field, string toolType, doubl c.Tool.SetToolType(toolType); c.Tool.TrailingHitchLength = double.IsNaN(hitchLength) ? 4 : hitchLength; if (RearSteer) c.Vehicle.Type = VehicleType.Harvester; - if (!double.IsNaN(hitchLength) || RearSteer) c.Tool.HitchLength = double.IsNaN(hitchLength) ? 4 : hitchLength; + if (!double.IsNaN(hitchLength) || RearSteer) + { + // A fixed tool's working point from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = Math.Abs(double.IsNaN(hitchLength) ? 4 : hitchLength); + c.Tool.WorkingPointFromHitch = 0; + } c.Guidance.UTurnRadius = 6; // AGOPENWEB_SCENARIO_DISTANCE overrides the U-turn distance from boundary (m) for a run. c.Guidance.UTurnDistanceFromBoundary = double.TryParse(Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_DISTANCE"), diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleUTurnTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleUTurnTests.cs index 36ad2f57a..4e9c01d89 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleUTurnTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/ObstacleUTurnTests.cs @@ -183,8 +183,10 @@ public void ATurnWhoseExitRunsTheImplementIntoAHardObstacle_IsNotMadeThere() path.Add(new Vec3(b.Easting + (b.Easting - a.Easting) / len * d, b.Northing + (b.Northing - a.Northing) / len * d, b.Heading)); var fence = ditch.Points.Select(p => new Vec2(p.Easting, p.Northing)).ToList(); var swept = ImplementSweptPath.Compute(path, new ToolGeometry( - ToolMount.RearFixed, Width: 6, Offset: 0, VehicleHitchLength: c.Vehicle.HitchLength, - ToolHitchLength: c.Tool.HitchLength, TrailingHitchLength: 0, TrailingToolToPivotLength: 0, + ToolMount.RearFixed, Width: 6, Offset: 0, + AttachX: RigGeometry.AttachX(ToolMount.RearFixed, c.Vehicle.HitchLength, c.Vehicle.ThreePointHitchLength, + c.Vehicle.FrontHitchLength, c.Tool.WorkingPointFromHitch), + TrailingHitchLength: 0, TrailingToolToPivotLength: 0, TankTrailingHitchLength: 0, Length: c.Tool.Length)); foreach (var corner in swept.FrontLeft.Concat(swept.FrontRight).Concat(swept.RearLeft).Concat(swept.RearRight)) Assert.That(GeometryMath.IsPointInPolygon(fence, new Vec2(corner.Easting, corner.Northing)), Is.False, @@ -212,7 +214,9 @@ public void AHardObstacle_KeepsTheImplementOutOfIt() { var c = ConfigurationStore.Instance; c.Tool.SetSectionWidth(0, 1200); // 12 m boom, 3-point mounted - c.Tool.HitchLength = 3; + // A fixed tool's working point 3 m from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = System.Math.Abs(3); + c.Tool.WorkingPointFromHitch = 0; c.Tool.IsToolRearFixed = true; var boundary = Field(ObstacleAction.Turn, hard: true); @@ -226,8 +230,7 @@ public void AHardObstacle_KeepsTheImplementOutOfIt() Assert.That(result.AtObstacle, Is.True); var fence = boundary.InnerBoundaries[0].Points.Select(p => new Vec2(p.Easting, p.Northing)).ToList(); var swept = ImplementSweptPath.Compute(result.Path, new ToolGeometry( - ToolMount.RearFixed, Width: 12, Offset: 0, VehicleHitchLength: c.Vehicle.HitchLength, - ToolHitchLength: 3, TrailingHitchLength: 0, TrailingToolToPivotLength: 0, + ToolMount.RearFixed, Width: 12, Offset: 0, AttachX: -3, TrailingHitchLength: 0, TrailingToolToPivotLength: 0, TankTrailingHitchLength: 0, Length: c.Tool.Length)); foreach (var corner in swept.FrontLeft.Concat(swept.FrontRight).Concat(swept.RearLeft).Concat(swept.RearRight)) Assert.That(GeometryMath.IsPointInPolygon(fence, new Vec2(corner.Easting, corner.Northing)), Is.False, diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnReferenceAheadTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnReferenceAheadTests.cs index 372ab23cb..f663c6fc1 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnReferenceAheadTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnReferenceAheadTests.cs @@ -72,7 +72,9 @@ public void A_turn_seated_back_behind_the_tractor_to_clear_a_hard_boundary_is_re var c = ConfigurationStore.Instance; c.Tool.Width = 6; c.Tool.Overlap = 0; - c.Tool.HitchLength = 4.5; + // A fixed tool's working point 4.5 m from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = System.Math.Abs(4.5); + c.Tool.WorkingPointFromHitch = 0; c.Tool.Length = 3.5; c.Tool.IsToolRearFixed = true; c.Tool.IsToolTrailing = false; diff --git a/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnSetbackStaysAtEndTests.cs b/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnSetbackStaysAtEndTests.cs index 55353f091..125e1b585 100644 --- a/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnSetbackStaysAtEndTests.cs +++ b/Tests/AgOpenWeb.Services.Tests/YouTurn/TurnSetbackStaysAtEndTests.cs @@ -18,10 +18,11 @@ namespace AgOpenWeb.Services.Tests.YouTurn; /// /// A turn tried again with the turn line moved in, to keep the implement off a hard fence, is /// seated back from the end of the pass the first try found. Moved in, the fence's turn line also -/// crossed the pass where the fence bulges in beside it, and the first crossing ahead was that -/// one: on Tapada da Casa a front 8 m boom near the fence at the far end moved the turn line in -/// 5 m, the turn was made 260 m before the end, beside the bulge, and landed on the next pass 16 m -/// after its edge entered the crop, which was never sprayed. Fork only. +/// crossed the pass where the fence comes in beside it, and the first crossing ahead was that +/// one: on a real field a front 8 m boom near the fence at the far end moved the turn line in +/// 5 m, the turn was made 260 m before the end, beside the narrowing, and landed on the next pass +/// 16 m after its edge entered the crop, which was never sprayed. A rear-mounted implement +/// gives the same. /// [TestFixture, NonParallelizable] // ConfigurationStore is a singleton. public class TurnSetbackStaysAtEndTests @@ -29,27 +30,31 @@ public class TurnSetbackStaysAtEndTests private const double FenceNorth = 100; // The east fence comes in to x = 9 from y = -15 to 15, with 45° sides: the pass x = 4 runs - // past it, the boom's east end (x = 8) a metre inside. + // past it, the implement's east end (x = 8) a metre inside. private static List Fence(bool bulge) => bulge ? new() { new(-50, -100), new(50, -100), new(50, -56), new(9, -15), new(9, 15), new(50, 56), new(50, FenceNorth), new(-50, FenceNorth) } : new() { new(-50, -100), new(50, -100), new(50, FenceNorth), new(-50, FenceNorth) }; - private static YouTurnCreationService NewCreation() + private static YouTurnCreationService NewCreation(bool front) { ConfigurationStore.SetInstance(new ConfigurationStore()); var c = ConfigurationStore.Instance; - // The Puma's sprayer: an 8 m boom fixed 4.5 m ahead of the pivot. + // A front sprayer: an 8 m boom fixed 4.5 m ahead of the pivot; or an 8 m implement fixed + // 1.5 m behind it. + const double width = 8; c.Vehicle.Wheelbase = 2.9; - c.Tool.Width = 8; + c.Tool.Width = width; c.Tool.Overlap = 0; c.Tool.Offset = 0; - c.Tool.HitchLength = 4.5; - c.Tool.IsToolFrontFixed = true; - c.Tool.IsToolRearFixed = false; + // A fixed tool's working point front ? 4.5 : 1.5 m from the rear axle: the tractor's hitch there, the tool on it (#547). + c.Vehicle.ThreePointHitchLength = c.Vehicle.FrontHitchLength = System.Math.Abs(front ? 4.5 : 1.5); + c.Tool.WorkingPointFromHitch = 0; + c.Tool.IsToolFrontFixed = front; + c.Tool.IsToolRearFixed = !front; c.Tool.IsToolTrailing = false; c.Tool.IsToolTBT = false; c.NumSections = 1; - c.Tool.SetSectionWidth(0, 800); + c.Tool.SetSectionWidth(0, (int)(width * 100)); c.Guidance.UTurnDistanceFromBoundary = 2.0; c.Guidance.UTurnRadius = 8.0; c.Guidance.UTurnExtension = 20.0; @@ -97,8 +102,9 @@ private static YouTurnCreationService.TurnPathResult TurnFromTheSouth(YouTurnCre uTurnSkipRows: 0, headlandCalculatedWidth: 12, headlandDistance: FenceNorth - 2 - y); } - [Test] - public void A_turn_seated_back_from_a_hard_fence_stays_at_the_end_of_the_pass() + [TestCase(true, TestName = "A turn seated back from a hard fence stays at the end of the pass (front boom)")] + [TestCase(false, TestName = "A turn seated back from a hard fence stays at the end of the pass (rear implement)")] + public void A_turn_seated_back_from_a_hard_fence_stays_at_the_end_of_the_pass(bool front) { Assert.Multiple(() => { @@ -107,10 +113,10 @@ public void A_turn_seated_back_from_a_hard_fence_stays_at_the_end_of_the_pass() }); // Without the hard fence the turn is made at the end, against the turn line; with it the - // boom swinging round ahead of the tractor is kept off the fence by seating the turn back. - var soft = TurnFromTheSouth(NewCreation(), Field(bulge: true, hard: false)); - var plain = TurnFromTheSouth(NewCreation(), Field(bulge: false, hard: true)); - var turn = TurnFromTheSouth(NewCreation(), Field(bulge: true, hard: true)); + // implement swinging round is kept off the fence by seating the turn back. + var soft = TurnFromTheSouth(NewCreation(front), Field(bulge: true, hard: false)); + var plain = TurnFromTheSouth(NewCreation(front), Field(bulge: false, hard: true)); + var turn = TurnFromTheSouth(NewCreation(front), Field(bulge: true, hard: true)); Assert.That(soft.Path, Is.Not.Null); Assert.That(plain.Path, Is.Not.Null); diff --git a/Tests/AgOpenWeb.SteerWizardScenarios.Tests/AgOpenWeb.SteerWizardScenarios.Tests.csproj b/Tests/AgOpenWeb.SteerWizardScenarios.Tests/AgOpenWeb.SteerWizardScenarios.Tests.csproj index 137a3cd57..ecabd8109 100644 --- a/Tests/AgOpenWeb.SteerWizardScenarios.Tests/AgOpenWeb.SteerWizardScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.SteerWizardScenarios.Tests/AgOpenWeb.SteerWizardScenarios.Tests.csproj @@ -44,6 +44,7 @@ + diff --git a/Tests/AgOpenWeb.SteerWizardScenarios.Tests/SteerWizardScenarioTests.cs b/Tests/AgOpenWeb.SteerWizardScenarios.Tests/SteerWizardScenarioTests.cs index e0e11cb9b..197edc192 100644 --- a/Tests/AgOpenWeb.SteerWizardScenarios.Tests/SteerWizardScenarioTests.cs +++ b/Tests/AgOpenWeb.SteerWizardScenarios.Tests/SteerWizardScenarioTests.cs @@ -27,8 +27,7 @@ public async Task Careful_and_careless_operators() TestContext.Progress.WriteLine("scenario: " + name); await run(journal, name); } - var path = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "steer-wizard-report.md"); + var path = ReportPath.For("steer-wizard-report.md"); File.WriteAllText(path, journal.ToMarkdown("Steer wizard scenarios", "Every step of the steer wizard driven by a careful and a careless operator against an emulated AiO v4 steer module and a moving tractor. Generated by Tests/AgOpenWeb.SteerWizardScenarios.Tests.")); TestContext.Progress.WriteLine("report: " + path); diff --git a/Tests/AgOpenWeb.UpdateScenarios.Tests/AgOpenWeb.UpdateScenarios.Tests.csproj b/Tests/AgOpenWeb.UpdateScenarios.Tests/AgOpenWeb.UpdateScenarios.Tests.csproj index 7d958af47..23461005c 100644 --- a/Tests/AgOpenWeb.UpdateScenarios.Tests/AgOpenWeb.UpdateScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.UpdateScenarios.Tests/AgOpenWeb.UpdateScenarios.Tests.csproj @@ -51,6 +51,7 @@ + diff --git a/Tests/AgOpenWeb.UpdateScenarios.Tests/Farm.cs b/Tests/AgOpenWeb.UpdateScenarios.Tests/Farm.cs index 0bbff9d5f..7e139a13d 100644 --- a/Tests/AgOpenWeb.UpdateScenarios.Tests/Farm.cs +++ b/Tests/AgOpenWeb.UpdateScenarios.Tests/Farm.cs @@ -251,6 +251,23 @@ public static class FarmBuilder { private static readonly CultureInfo Inv = CultureInfo.InvariantCulture; + /// + /// A setting as the build it goes to names it. A fixed implement's distance was the rear axle → + /// working point (tool.hitchLength) up to profile format 2; this build keeps it from the + /// tractor's 3-point hitch (#547), so the same place is the distance less the hitch of the + /// vehicle in use. The readback (Tool.HitchLength in the dump) is the axle → working point on + /// every build. + /// + private static string ThisBuildKey(ICommandSender s, Setting st) + { + if (s is RigSender rig && st.Key == "tool.hitchLength") + { + double fromHitch = double.Parse(st.Value, Inv) - rig.Rig.Store.Vehicle.ThreePointHitchLength; + return "tool.workingPointFromHitch:" + fromHitch.ToString(Inv); + } + return st.Key + ":" + st.Value; + } + public static async Task BuildAsync(ICommandSender s, Farm farm, Scenario sc, bool slow) { // Settle a fresh install: the simulator on, standing still. @@ -272,7 +289,7 @@ public static async Task BuildAsync(ICommandSender s, Farm farm, Scenario sc, bo { await s.SendAsync("profile.new", "tool\t" + t.Name); await s.PauseAsync(slow ? 1.0 : 0); - foreach (var st in t.Settings) await s.SendAsync("config.set", st.Key + ":" + st.Value); + foreach (var st in t.Settings) await s.SendAsync("config.set", ThisBuildKey(s, st)); await s.SendAsync("profile.save"); await s.PauseAsync(slow ? 1.0 : 0); sc.Step(Kit.F($"implement profile '{t.Name}' made with {t.Settings.Count} settings and saved")); diff --git a/Tests/AgOpenWeb.UpdateScenarios.Tests/Snapshot.cs b/Tests/AgOpenWeb.UpdateScenarios.Tests/Snapshot.cs index 8b545e9c2..f113bfdb1 100644 --- a/Tests/AgOpenWeb.UpdateScenarios.Tests/Snapshot.cs +++ b/Tests/AgOpenWeb.UpdateScenarios.Tests/Snapshot.cs @@ -181,6 +181,10 @@ public static void DumpStore(Dictionary d, UpdateRig rig) DumpObject(d, "Hotkeys", st.Hotkeys); d["NumSections"] = st.NumSections.ToString(Inv); d["ActualToolWidth"] = Num(st.ActualToolWidth); + // A fixed implement's working point from the rear axle: format 2's Tool.HitchLength, which this + // build splits into the vehicle's hitch and the tool's distance from it (#547). Older builds + // read it straight from the store, so every build is compared on the same number. + d["Tool.HitchLength"] = Num(AgOpenWeb.Models.Tool.RigGeometry.AxleToWorkingPoint(st.Vehicle, st.Tool)); d["ActiveVehicleProfileName"] = st.ActiveVehicleProfileName; d["ActiveToolProfileName"] = st.ActiveToolProfileName; d["IsMetric"] = st.IsMetric.ToString(); diff --git a/Tests/AgOpenWeb.UpdateScenarios.Tests/UpdateScenarioTests.cs b/Tests/AgOpenWeb.UpdateScenarios.Tests/UpdateScenarioTests.cs index a73e7bf83..f2865c6ac 100644 --- a/Tests/AgOpenWeb.UpdateScenarios.Tests/UpdateScenarioTests.cs +++ b/Tests/AgOpenWeb.UpdateScenarios.Tests/UpdateScenarioTests.cs @@ -29,8 +29,7 @@ public async Task Upgrader_and_restarter() if (who is null or "restarter") await RestarterOperator.RunAsync(journal, only); if (who is null or "upgrader") await UpgraderOperator.RunAsync(journal, only); - string report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "update-scenarios-report.md"); + string report = ReportPath.For("update-scenarios-report.md"); File.WriteAllText(report, journal.ToMarkdown()); TestContext.Out.WriteLine("Report: " + report); foreach (var g in journal.Findings.Values.OrderBy(v => v.First.Severity)) diff --git a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/AgOpenWeb.VehicleToolScenarios.Tests.csproj b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/AgOpenWeb.VehicleToolScenarios.Tests.csproj index 94b144374..3500bc95c 100644 --- a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/AgOpenWeb.VehicleToolScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/AgOpenWeb.VehicleToolScenarios.Tests.csproj @@ -46,6 +46,7 @@ + diff --git a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/CarelessOperator.cs b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/CarelessOperator.cs index a8e25bc85..4962cb84d 100644 --- a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/CarelessOperator.cs +++ b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/CarelessOperator.cs @@ -77,13 +77,13 @@ private static IEnumerable Drives() yield return new("T-OV8", "Overlap typed as wide as the boom (8 m)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.overlap:8"), Impossible: true); yield return new("T-OV10", "Overlap typed wider than the boom (10 m)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.overlap:10"), Impossible: true); yield return new("T-OVCM", "Overlap typed in cm (20 in the metre box)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.overlap:20"), Impossible: true); - yield return new("T-RIG0", "Boom distance behind the axle left at 0 (really 1.5 m)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.hitchLength:0")); - yield return new("T-RIGCM", "Boom distance typed in cm (150)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.hitchLength:150"), Impossible: true); + yield return new("T-RIG0", "Boom distance behind the axle left at 0 (really 1.5 m)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "vehicle.threePointHitchLength:0")); + yield return new("T-RIGCM", "Boom distance typed in cm (150)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "vehicle.threePointHitchLength:150"), Impossible: true); yield return new("T-TRAILNEG", "Trailed sprayer's drawbar typed negative (-4 m)", Puma, Machines.Sprayer10, Um, 0, W(Puma, Machines.Sprayer10, Um, "tool.trailingHitchLength:-4"), Impossible: true); yield return new("T-TRAIL0", "Trailed sprayer's drawbar left at 0", Puma, Machines.Sprayer10, Um, 0, W(Puma, Machines.Sprayer10, Um, "tool.trailingHitchLength:0"), Impossible: true); yield return new("T-TYPEXYZ", "Implement type that does not exist ('xyz')", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.type:xyz"), Impossible: true); yield return new("T-TYPERIGID", "Trailed sprayer set as mounted (rear, 4 m behind)", Puma, Machines.Sprayer10, Um, 0, - W(Puma, Machines.Sprayer10, Um, "tool.type:rear", "tool.hitchLength:4")); + W(Puma, Machines.Sprayer10, Um, "tool.type:rear", "vehicle.threePointHitchLength:4", "tool.workingPointFromHitch:0")); yield return new("T-TBTASTRAIL", "TBT seed drill set as a plain trailed one", Machines.Big, Machines.Tbt12, Machines.BigSys, 0, W(Machines.Big, Machines.Tbt12, Machines.BigSys, "tool.type:trailing")); yield return new("T-SLOWCUT", "Slow speed cut-off typed 100 km/h (work speed 9)", Puma, Boom, Um, 0, W(Puma, Boom, Um, "tool.slowSpeedCutoff:100"), Impossible: true); @@ -223,8 +223,8 @@ private static IEnumerable BadSettings() yield return new("Implement", "Overlap = width", "tool.overlap:8"); yield return new("Implement", "Overlap > width", "tool.overlap:10"); yield return new("Implement", "Gap wider than the implement", "tool.overlap:-10"); - yield return new("Implement", "Mounted distance negative", "tool.hitchLength:-1.5"); - yield return new("Implement", "Mounted distance in cm", "tool.hitchLength:150"); + yield return new("Implement", "Mounted distance negative", "vehicle.threePointHitchLength:-1.5"); + yield return new("Implement", "Mounted distance in cm", "vehicle.threePointHitchLength:150"); yield return new("Implement", "Drawbar negative", "tool.trailingHitchLength:-4"); yield return new("Implement", "Drawbar 0", "tool.trailingHitchLength:0"); yield return new("Implement", "TBT tank hitch negative", "tool.tankTrailingHitchLength:-3"); diff --git a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Machines.cs b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Machines.cs index caa9cdd87..9a6fb9957 100644 --- a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Machines.cs +++ b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Machines.cs @@ -65,7 +65,14 @@ public IEnumerable Settings() if (SectionWidthCm is >= 10 and <= 1000) yield return "tool.defaultSectionWidth:" + SectionWidthCm.ToString("0.###", i); yield return "tool.numSections:" + Sections.ToString(i); for (int s = 0; s < Sections; s++) yield return "tool.sectionWidth:" + s.ToString(i) + "," + SectionWidthCm.ToString("0.###", i); - if (Type is "front" or "rear") yield return "tool.hitchLength:" + HitchLength.ToString(i); + // A fixed implement's working point HitchLength from the rear axle: the tractor's 3-point + // (front hitch for a front implement) there and the implement at it (#547), as the + // AgOpenGPS import does. + if (Type is "front" or "rear") + { + yield return (Type == "front" ? "vehicle.frontHitchLength:" : "vehicle.threePointHitchLength:") + HitchLength.ToString(i); + yield return "tool.workingPointFromHitch:0"; + } else { yield return "tool.trailingHitchLength:" + HitchLength.ToString(i); diff --git a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Readback.cs b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Readback.cs index 30ffce9e4..087acedbb 100644 --- a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Readback.cs +++ b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/Readback.cs @@ -29,6 +29,8 @@ public static string Get(ConfigurationStore s, string key) "vehicle.antennaPivot" => D(v.AntennaPivot), "vehicle.antennaOffset" => D(v.AntennaOffset), "vehicle.hitchLength" => D(v.HitchLength), + "vehicle.threePointHitchLength" => D(v.ThreePointHitchLength), + "vehicle.frontHitchLength" => D(v.FrontHitchLength), "autosteer.maxSteerAngle" => D(v.MaxSteerAngle), "gps.isDualGps" => c.IsDualGps ? "1" : "0", "gps.dualHeadingOffset" => D(c.DualHeadingOffset), @@ -37,7 +39,7 @@ public static string Get(ConfigurationStore s, string key) "roll.rollFilter" => D(a.RollFilter), "tool.type" => t.IsToolFrontFixed ? "front" : t.IsToolRearFixed ? "rear" : t.IsToolTBT ? "tbt" : t.IsToolTrailing ? "trailing" : "none", "tool.numSections" => s.NumSections.ToString(i), - "tool.hitchLength" => D(t.HitchLength), + "tool.workingPointFromHitch" => D(t.WorkingPointFromHitch), "tool.trailingHitchLength" => D(t.TrailingHitchLength), "tool.trailingToolToPivotLength" => D(t.TrailingToolToPivotLength), "tool.tankTrailingHitchLength" => D(t.TankTrailingHitchLength), diff --git a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/SettingsSweep.cs b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/SettingsSweep.cs index 9b9a5154d..ff6275250 100644 --- a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/SettingsSweep.cs +++ b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/SettingsSweep.cs @@ -172,7 +172,8 @@ public static class SettingsSweep // flip would be set back to 0 and try nothing. new("tool.lookAheadOff", "0", "1.5", Probe.Sections, Effect.Sections, "Look-ahead off, s", "tool.lookAheadOn:2"), new("tool.turnOffDelay", "0", "2", Probe.Sections, Effect.Sections, "Turn-off delay, s"), - new("tool.hitchLength", "1.5", "4", Probe.Sections, Effect.Sections, "Hitch length (mounted)"), + new("vehicle.threePointHitchLength", "1.5", "4", Probe.Sections, Effect.Sections, "Rear 3-point hitch (mounted)"), + new("tool.workingPointFromHitch", "0", "2.5", Probe.Sections, Effect.Sections, "Hitch to working point (mounted)"), // AgOpenGPS uses the vehicle type for the picture and, for a combine, a header in front only. new("vehicle.type", "0", "1", Probe.Sections, Effect.Sections, "Vehicle type (tractor → combine: header in front)"), new("tool.type", "rear", "front", Probe.Sections, Effect.Sections, "Implement type (rear → front)"), diff --git a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/VehicleToolScenarioTests.cs b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/VehicleToolScenarioTests.cs index e739b5454..4c1089ce3 100644 --- a/Tests/AgOpenWeb.VehicleToolScenarios.Tests/VehicleToolScenarioTests.cs +++ b/Tests/AgOpenWeb.VehicleToolScenarios.Tests/VehicleToolScenarioTests.cs @@ -33,8 +33,7 @@ public async Task Careful_and_careless_operator() if (Wants("sweep")) { Progress("settings sweep"); await SettingsSweep.RunAsync(journal, only, Progress); } Progress("done"); - string report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "vehicle-tool-scenarios-report.md"); + string report = ReportPath.For("vehicle-tool-scenarios-report.md"); File.WriteAllText(report, journal.ToMarkdown()); TestContext.Out.WriteLine("Report: " + report); foreach (var g in journal.Findings.Values.OrderBy(v => v.First.Severity)) diff --git a/Tests/AgOpenWeb.ViewModels.Tests/AutoSteerOffWhenSettingsChangeTests.cs b/Tests/AgOpenWeb.ViewModels.Tests/AutoSteerOffWhenSettingsChangeTests.cs index db605252c..6831623ee 100644 --- a/Tests/AgOpenWeb.ViewModels.Tests/AutoSteerOffWhenSettingsChangeTests.cs +++ b/Tests/AgOpenWeb.ViewModels.Tests/AutoSteerOffWhenSettingsChangeTests.cs @@ -44,7 +44,8 @@ private static IEnumerable SteeringChanges() yield return new TestCaseData((Action)(s => s.Tool.SetSectionWidth(3, 150)), "implement").SetName("A section width"); yield return new TestCaseData((Action)(s => s.Tool.Offset = 2.5), "implement").SetName("Implement offset"); yield return new TestCaseData((Action)(s => s.Tool.Overlap = 0.2), "implement").SetName("Overlap"); - yield return new TestCaseData((Action)(s => s.Tool.HitchLength = 3), "implement").SetName("Implement hitch"); + yield return new TestCaseData((Action)(s => s.Tool.WorkingPointFromHitch = 3), "implement").SetName("Implement hitch"); + yield return new TestCaseData((Action)(s => s.Vehicle.ThreePointHitchLength = 0.7), "vehicle").SetName("Rear 3-point hitch"); yield return new TestCaseData((Action)(s => s.Vehicle.AntennaOffset = 0.25), "vehicle").SetName("Antenna offset"); yield return new TestCaseData((Action)(s => s.Vehicle.AntennaHeight = 1), "vehicle").SetName("Antenna height"); yield return new TestCaseData((Action)(s => s.Vehicle.Wheelbase = 3.5), "vehicle").SetName("Wheelbase"); diff --git a/Tests/AgOpenWeb.ViewModels.Tests/JobRigRecordingTests.cs b/Tests/AgOpenWeb.ViewModels.Tests/JobRigRecordingTests.cs new file mode 100644 index 000000000..734352130 --- /dev/null +++ b/Tests/AgOpenWeb.ViewModels.Tests/JobRigRecordingTests.cs @@ -0,0 +1,65 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +using AgOpenWeb.Models.Configuration; +using AgOpenWeb.Models.Job; +using NSubstitute; +using NUnit.Framework; + +namespace AgOpenWeb.ViewModels.Tests; + +/// +/// The active job keeps the machine it is worked with (#547): the view model hands the job service +/// the loaded vehicle and tool when a job becomes active and whenever a profile is loaded or saved. +/// +[TestFixture] +[NonParallelizable] // ConfigurationStore is a singleton +public class JobRigRecordingTests +{ + private MainViewModelBuilder _b = null!; + + [SetUp] + public void SetUp() + { + ConfigurationStore.SetInstance(new ConfigurationStore()); + var s = ConfigurationStore.Instance; + s.ActiveVehicleProfileName = "Fendt 312"; + s.Vehicle.ThreePointHitchLength = 1.2; + s.Tool.SetToolType("rear"); + s.Tool.WorkingPointFromHitch = 0.8; + _b = new MainViewModelBuilder(); + _b.Build(); + } + + private static bool IsLoaded(JobRig r) => + r.Vehicle.ProfileName == "Fendt 312" && r.Vehicle.RearThreePointHitch == 1.2 + && r.Tools.Count == 1 && r.Tools[0].WorkingPointFromHitch == 0.8; + + [Test] + public void A_job_that_becomes_active_gets_the_loaded_machine() + { + var job = new Job { FieldName = "north40", TaskName = "t1" }; + _b.JobService.ActiveJob.Returns(job); + _b.JobService.ActiveJobChanged += Raise.Event>(_b.JobService, job); + _b.JobService.Received(1).RecordRig(Arg.Is(r => IsLoaded(r))); + } + + [Test] + public void A_profile_loaded_or_saved_during_the_job_updates_it() + { + _b.JobService.ActiveJob.Returns(new Job { FieldName = "north40", TaskName = "t1" }); + _b.ConfigurationService.ProfileSaved += Raise.Event>(_b.ConfigurationService, "Fendt 312"); + _b.ConfigurationService.ProfileLoaded += Raise.Event>(_b.ConfigurationService, "Fendt 312"); + _b.JobService.Received(2).RecordRig(Arg.Is(r => IsLoaded(r))); + } + + [Test] + public void No_active_job_nothing_is_recorded() + { + _b.JobService.ActiveJob.Returns((Job?)null); + _b.ConfigurationService.ProfileSaved += Raise.Event>(_b.ConfigurationService, "Fendt 312"); + _b.JobService.ActiveJobChanged += Raise.Event>(_b.JobService, (Job?)null); + _b.JobService.DidNotReceive().RecordRig(Arg.Any()); + } +} diff --git a/Tests/AgOpenWeb.WebClientScenarios.Tests/AgOpenWeb.WebClientScenarios.Tests.csproj b/Tests/AgOpenWeb.WebClientScenarios.Tests/AgOpenWeb.WebClientScenarios.Tests.csproj index 26ddf87ee..08548b263 100644 --- a/Tests/AgOpenWeb.WebClientScenarios.Tests/AgOpenWeb.WebClientScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.WebClientScenarios.Tests/AgOpenWeb.WebClientScenarios.Tests.csproj @@ -51,6 +51,7 @@ + diff --git a/Tests/AgOpenWeb.WebClientScenarios.Tests/WebClientScenarioTests.cs b/Tests/AgOpenWeb.WebClientScenarios.Tests/WebClientScenarioTests.cs index d8bf1ad02..52b73015e 100644 --- a/Tests/AgOpenWeb.WebClientScenarios.Tests/WebClientScenarioTests.cs +++ b/Tests/AgOpenWeb.WebClientScenarios.Tests/WebClientScenarioTests.cs @@ -18,8 +18,7 @@ public class WebClientScenarioTests public async Task Web_client_scenarios() { var clock = System.Diagnostics.Stopwatch.StartNew(); - var report = Environment.GetEnvironmentVariable("AGOPENWEB_SCENARIO_REPORT"); - if (string.IsNullOrEmpty(report)) report = Path.Combine(TestContext.CurrentContext.WorkDirectory, "web-client-scenarios-report.md"); + var report = ReportPath.For("web-client-scenarios-report.md"); var shots = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(report))!, "web-client-shots"); if (Directory.Exists(shots)) Directory.Delete(shots, true); var dataRoot = Path.Combine(Path.GetTempPath(), "aow-web-scenarios-" + Guid.NewGuid().ToString("N")[..8]); diff --git a/Tests/AgOpenWeb.WorkingDayScenarios.Tests/AgOpenWeb.WorkingDayScenarios.Tests.csproj b/Tests/AgOpenWeb.WorkingDayScenarios.Tests/AgOpenWeb.WorkingDayScenarios.Tests.csproj index 57ce34d4a..0216858bb 100644 --- a/Tests/AgOpenWeb.WorkingDayScenarios.Tests/AgOpenWeb.WorkingDayScenarios.Tests.csproj +++ b/Tests/AgOpenWeb.WorkingDayScenarios.Tests/AgOpenWeb.WorkingDayScenarios.Tests.csproj @@ -54,6 +54,7 @@ + diff --git a/Tests/AgOpenWeb.WorkingDayScenarios.Tests/Machines.cs b/Tests/AgOpenWeb.WorkingDayScenarios.Tests/Machines.cs index 3bfdbc960..88afc716d 100644 --- a/Tests/AgOpenWeb.WorkingDayScenarios.Tests/Machines.cs +++ b/Tests/AgOpenWeb.WorkingDayScenarios.Tests/Machines.cs @@ -63,7 +63,14 @@ public IEnumerable Settings() yield return "tool.type:" + Type; yield return "tool.numSections:" + Sections.ToString(i); for (int s = 0; s < Sections; s++) yield return "tool.sectionWidth:" + s.ToString(i) + "," + SectionWidthCm.ToString("0.###", i); - if (Type is "front" or "rear") yield return "tool.hitchLength:" + HitchLength.ToString(i); + // A fixed implement's working point HitchLength from the rear axle: the tractor's 3-point + // (front hitch for a front implement) there and the implement at it (#547), as the + // AgOpenGPS import does. + if (Type is "front" or "rear") + { + yield return (Type == "front" ? "vehicle.frontHitchLength:" : "vehicle.threePointHitchLength:") + HitchLength.ToString(i); + yield return "tool.workingPointFromHitch:0"; + } else { yield return "tool.trailingHitchLength:" + HitchLength.ToString(i); diff --git a/Tests/AgOpenWeb.WorkingDayScenarios.Tests/WorkingDayScenarioTests.cs b/Tests/AgOpenWeb.WorkingDayScenarios.Tests/WorkingDayScenarioTests.cs index fdf817fe9..36183f848 100644 --- a/Tests/AgOpenWeb.WorkingDayScenarios.Tests/WorkingDayScenarioTests.cs +++ b/Tests/AgOpenWeb.WorkingDayScenarios.Tests/WorkingDayScenarioTests.cs @@ -28,8 +28,7 @@ public async Task Working_day(CancellationToken ct) day.CountCensuses(); var report = Report.Markdown(day); - string path = Environment.GetEnvironmentVariable("AGOPENWEB_DAY_REPORT") - ?? Path.Combine(TestContext.CurrentContext.WorkDirectory, "working-day-scenarios-report.md"); + string path = ReportPath.For("working-day-scenarios-report.md", "AGOPENWEB_DAY_REPORT"); await File.WriteAllTextAsync(path, report, CancellationToken.None); if (Environment.GetEnvironmentVariable("AGOPENWEB_DAY_CSV") is { Length: > 0 } csv) await File.WriteAllTextAsync(csv, Report.Csv(day), CancellationToken.None); diff --git a/Tests/ScenarioShared/ReportPath.cs b/Tests/ScenarioShared/ReportPath.cs new file mode 100644 index 000000000..eaad8909f --- /dev/null +++ b/Tests/ScenarioShared/ReportPath.cs @@ -0,0 +1,34 @@ +// AgOpenWeb +// Copyright (C) 2026 AgOpenWeb Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Linked into every scenario harness (). + +using System; +using System.IO; +using NUnit.Framework; + +/// +/// Where a harness writes its report. The variable (AGOPENWEB_SCENARIO_REPORT, the working day's +/// AGOPENWEB_DAY_REPORT) names the report's file, or a folder (ending in a slash, or one that +/// exists) that the report goes into under its own name; unset, the report goes to the test's +/// work folder under its own name. A folder keeps the reports of a project with several fixtures +/// apart: the field job harness has six, and with one file named for all of them the last fixture +/// run overwrote the others, so a Runner comparison read a report without the jobs it was run for +/// (2026-11-13). +/// +public static class ReportPath +{ + public static string For(string reportName, string variable = "AGOPENWEB_SCENARIO_REPORT") + { + string? v = Environment.GetEnvironmentVariable(variable); + if (string.IsNullOrEmpty(v)) + return Path.Combine(TestContext.CurrentContext.WorkDirectory, reportName); + if (v.EndsWith('/') || v.EndsWith('\\') || Directory.Exists(v)) + { + Directory.CreateDirectory(v); + return Path.Combine(v, reportName); + } + return v; + } +} diff --git a/Tools/combine-job-reports.py b/Tools/combine-job-reports.py index 5bd4e142b..ebfdf8bc6 100755 --- a/Tools/combine-job-reports.py +++ b/Tools/combine-job-reports.py @@ -7,7 +7,8 @@ combine-job-reports.py OUT.md LABEL=REPORT.md [LABEL=REPORT.md ...] Each report starts with "## Findings (each kind once, most serious first)": numbered entries -"N. **Severity** `CODE` ×k: text" with " Seen in: J1, J2" under them. The jobs share one +"N. **Severity** `CODE` ×k: text" with " Seen in: J1, J2" under them (or a table, "| Code | +Severity | ...", in the web client and working day reports). The jobs share one engine, so one cause shows up in several reports under the same code; the combined report lists each code once, with every harness and job it was seen in and the first harness's text, most serious first. The full reports stay as their own artifacts. @@ -18,6 +19,27 @@ ORDER = ["Safety", "DataLoss", "Bug", "Inconsistency", "Improvement", "Harness"] # Some harnesses put the kind after the severity: "**Bug** (app defect) `CODE`" (dump replay). ENTRY = re.compile(r"^\d+\. \*\*(?P[A-Za-z]+)\*\* (?:\([^)]*\) )?`(?P[A-Z0-9-]+)` ×(?P\d+): (?P.*)$") +# The web client and working day harnesses list them as a table instead: "| Code | Severity | ..." +# with the text in the last column ("First seen as" or "Finding") and, in the web client's, +# the scenarios it was seen in ("Scenarios"). Cells escape "|" as "\|". +CELL_SPLIT = re.compile(r"(? is a branch, a commit, # or pr/ (a pull request's head, forks included). Reports go to /out -# (AGOPENWEB_SCENARIO_REPORT and the working day's AGOPENWEB_DAY_REPORT default to /out/report.md); +# (AGOPENWEB_SCENARIO_REPORT and the working day's AGOPENWEB_DAY_REPORT default to /out/, each report +# under its own name); # AGOPENWEB_* pass through with -e. set -euo pipefail ref="$1"; proj="$2"; shift 2 @@ -27,8 +28,12 @@ else fi git checkout -q "$ref" echo "== $(git log --oneline -1)" -export AGOPENWEB_SCENARIO_REPORT="${AGOPENWEB_SCENARIO_REPORT:-/out/report.md}" -export AGOPENWEB_DAY_REPORT="${AGOPENWEB_DAY_REPORT:-/out/report.md}" +# Each report goes to /out under its own name (Tests/ScenarioShared/ReportPath.cs), so a project +# with several fixtures (the field job harness's six) keeps them all. A ref from before that file +# writes the one /out/report.md, as it always did. +if [ -f Tests/ScenarioShared/ReportPath.cs ]; then out_report=/out/; else out_report=/out/report.md; fi +export AGOPENWEB_SCENARIO_REPORT="${AGOPENWEB_SCENARIO_REPORT:-$out_report}" +export AGOPENWEB_DAY_REPORT="${AGOPENWEB_DAY_REPORT:-$out_report}" # A failed build stops here with its errors. if ! dotnet build "$proj" -c "${CONFIG:-Debug}" --verbosity minimal > /out/build.log 2>&1; then grep -E "error" /out/build.log | sort -u | head -20 diff --git a/Tools/harness-runner/runner-compare.sh b/Tools/harness-runner/runner-compare.sh index 457249634..b564fea35 100755 --- a/Tools/harness-runner/runner-compare.sh +++ b/Tools/harness-runner/runner-compare.sh @@ -13,7 +13,8 @@ # Refs are branches, commits or pr/, fetched from GitHub on the host: push first. Every # AGOPENWEB_* variable set here, and CONFIG (default Release), go to both runs. The containers run # on the Docker context RUNNER_CONTEXT (default "runner"); their /out folders are copied to -# OUT_DIR (default ./harness-out/), and Tools/compare-reports.py writes compare.md there. +# OUT_DIR (default ./harness-out/), and Tools/compare-reports.py writes compare-.md there +# for each report. It exits 1 when a side did not pass, a report is on one side only, or there is none. set -euo pipefail [ $# -ge 3 ] || { sed -n 5,16p "$0"; exit 2; } fix="$1"; base="$2"; proj="$3"; shift 3 @@ -39,17 +40,52 @@ for side in fix base; do dk run -d --cpus "$cpus" --name "$name-$side" -v aow-cache:/cache "${envs[@]}" aow-harness:latest "$ref" "$proj" "$@" >/dev/null echo "== $side ($ref): $name-$side" done +problems=0 for side in fix base; do code=$(dk wait "$name-$side") mkdir -p "$out/$side" dk cp "$name-$side:/out/." "$out/$side" >/dev/null dk rm "$name-$side" >/dev/null - echo "== $side finished (exit $code): $(grep -hE 'Passed!|Failed!' "$out/$side/test.log" 2>/dev/null | head -1)" + result=$(grep -hE 'Passed!|Failed!' "$out/$side/test.log" 2>/dev/null | head -1 || true) + echo "== $side finished (exit $code): $result" + # A side whose tests failed may have stopped before its jobs ran: its report is no comparison. + case "$result" in + *Passed!*) ;; + *) echo "!! $side did not pass: read $out/$side/test.log before trusting the comparison"; problems=1 ;; + esac +done + +# Every report on either side, compared by name: a project with several fixtures writes one each +# (Tests/ScenarioShared/ReportPath.cs). A report on one side only is a problem, not a quiet run. +# A ref from before that wrote the one report.md; it is paired with the other side's report +# when that side has exactly one. +reports() { (cd "$out/$1" && ls *.md 2>/dev/null) || true; } +for side in fix base; do + other=base; [ $side = base ] && other=fix + mine=$(reports $side); theirs=$(reports $other) + [ "$mine" = report.md ] || continue + # Its fixtures all wrote that one file, the last one run over the others: only a run of one + # fixture (a --filter) left the report of the jobs asked for. + tests=$(grep -hoE 'Total: +[0-9]+' "$out/$side/test.log" 2>/dev/null | grep -oE '[0-9]+' | head -1 || true) + if [ "${tests:-0}" != 1 ]; then + echo "!! $side is from before per-fixture reports and ran ${tests:-no} tests into its one report.md: rerun with a --filter for one fixture" + problems=1 + elif [ -n "$theirs" ] && [ "$theirs" != report.md ] && [ "$(echo "$theirs" | wc -l | tr -d ' ')" = 1 ]; then + mv "$out/$side/report.md" "$out/$side/$theirs" + echo "== $side is from before per-fixture reports: its report.md is compared with $theirs" + fi +done +names=$( (reports fix; reports base) | sort -u) +[ -n "$names" ] || { echo "!! no report on either side; see $out/*/build.log and test.log"; problems=1; } +for r in $names; do + if [ ! -f "$out/base/$r" ] || [ ! -f "$out/fix/$r" ]; then + side=base; [ -f "$out/fix/$r" ] && side=fix + echo "!! $r: only on the $side side"; problems=1; continue + fi + cmp="$out/compare-${r%.md}.md" + python3 "$here/../compare-reports.py" "$out/base/$r" "$out/fix/$r" "$cmp" + echo "== $r" + sed -n 1,40p "$cmp" done -if [ -f "$out/base/report.md" ] && [ -f "$out/fix/report.md" ]; then - python3 "$here/../compare-reports.py" "$out/base/report.md" "$out/fix/report.md" "$out/compare.md" - sed -n 1,40p "$out/compare.md" -else - echo "A report is missing; see $out/*/build.log and test.log." -fi echo "== $out" +[ $problems -eq 0 ] || { echo "!! the comparison is incomplete: see the lines marked !! above"; exit 1; }