diff --git a/FS25_ADSmartPickup/adCpBridge.lua b/FS25_ADSmartPickup/adCpBridge.lua index 72a40b2..955e6af 100644 --- a/FS25_ADSmartPickup/adCpBridge.lua +++ b/FS25_ADSmartPickup/adCpBridge.lua @@ -74,6 +74,68 @@ function ADCpBridge.isWorking(vehicle) return vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive() or false end +-- Courseplays værktøjsforskydning (venstre positiv) på køretøjet; den der flytter kursuslinjen. +function ADCpBridge.getToolOffset(vehicle) + if vehicle == nil or vehicle.getCpSettings == nil then return 0 end + local ok, value = pcall(function() return vehicle:getCpSettings().toolOffsetX:getValue() end) + return ok and type(value) == "number" and value or 0 +end + +function ADCpBridge.setToolOffset(vehicle, value) + if vehicle == nil or vehicle.getCpSettings == nil then return false end + return (pcall(function() vehicle:getCpSettings().toolOffsetX:setFloatValue(value) end)) +end + +-- Riverens XML: workArea-nr (1-baseret) -> dropWindrowWorkAreaIndex. Bruges når spillet ikke har feltet +-- på workArea-tabellen. Cachet pr. fil. +ADCpBridge.dropIndexCache = {} +function ADCpBridge.readDropIndicesFromXml(fileName) + if ADCpBridge.dropIndexCache[fileName] ~= nil then return ADCpBridge.dropIndexCache[fileName] end + local indices = {} + local ok = pcall(function() + local xmlFile = XMLFile.load("adspuRake", fileName) + if xmlFile == nil then return end + local position = 0 + xmlFile:iterate("vehicle.workAreas.workArea", function(_, key) + position = position + 1 + local dropIndex = xmlFile:getInt(key .. ".windrower#dropWindrowWorkAreaIndex") + if dropIndex ~= nil then indices[position] = dropIndex end + end) + xmlFile:delete() + end) + if not ok then indices = {} end + ADCpBridge.dropIndexCache[fileName] = indices + return indices +end + +-- Hvor langt til venstre (positiv) for rivens kursuslinje skåret lægges: midten af nedlægningsområdet i +-- traktorens ramme minus rivens egen CP-forskydning. Midterrive ≈ 0, siderive fx 3 m. nil = ingen river. +function ADCpBridge.getWindrowOffset(vehicle) + local root = vehicle.rootNode + local sum, count = 0, 0 + for _, object in ipairs(collect(vehicle)) do + local areas = object.spec_windrower ~= nil and object.spec_workArea ~= nil and object.spec_workArea.workAreas or nil + if areas ~= nil then + local fromXml = nil + for position, workArea in ipairs(areas) do + local dropIndex = workArea.dropWindrowWorkAreaIndex + if dropIndex == nil and object.configFileName ~= nil then + fromXml = fromXml or ADCpBridge.readDropIndicesFromXml(object.configFileName) + dropIndex = fromXml[position] + end + local drop = dropIndex ~= nil and areas[dropIndex] or nil + if drop ~= nil and drop.width ~= nil and drop.height ~= nil then + local widthX = localToLocal(drop.width, root, 0, 0, 0) + local heightX = localToLocal(drop.height, root, 0, 0, 0) + sum, count = sum + (widthX + heightX) / 2, count + 1 + end + end + end + end + if count == 0 then return nil end + return sum / count - ADCpBridge.getToolOffset(vehicle) +end + -- Køretøjets nuværende Courseplay-kursus (slåmaskinens kursus genbruges af de næste trin). function ADCpBridge.getCourse(vehicle) if vehicle == nil or vehicle.getFieldWorkCourse == nil then return nil end diff --git a/FS25_ADSmartPickup/adFieldJobs.lua b/FS25_ADSmartPickup/adFieldJobs.lua index 36c2308..6e806a5 100644 --- a/FS25_ADSmartPickup/adFieldJobs.lua +++ b/FS25_ADSmartPickup/adFieldJobs.lua @@ -11,6 +11,8 @@ ADFieldJobs.ARRIVE_DISTANCE = 30 ADFieldJobs.IDLE_CONFIRM_MS = 5000 ADFieldJobs.START_TIMEOUT_MS = 60000 ADFieldJobs.PLAYER_DRIVING_KMH = 3 +-- riven skal være udfoldet og i gang, før nedlægningsområdet måles +ADFieldJobs.WINDROW_MEASURE_MS = 10000 -- Courseplay kan efterlade hjørner på skæve marker: efter så mange færdige slå-ture går marken videre. ADFieldJobs.MAX_MOW_PASSES = 2 ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}} @@ -239,7 +241,16 @@ local function forgetFinished(fieldId) ADFieldJobs.finished = remaining end +-- Læssevognens oprindelige CP-forskydning sættes tilbage, når mod'en slipper den. +local function restoreToolOffset(assignment) + if assignment.restoreToolOffset ~= nil then + ADCpBridge.setToolOffset(assignment.vehicle, assignment.restoreToolOffset) + assignment.restoreToolOffset = nil + end +end + local function release(assignment, phase) + restoreToolOffset(assignment) assignment.phase = phase or "released" ADFieldJobs.state.assignments[assignment.vehicleId] = nil end @@ -317,6 +328,15 @@ local function beginWork(adEnv, field, info, assignment) if source ~= nil and course == nil then log(field.id, "intet %s-kursus kendt — Courseplay laver et nyt kursus til %s", source, assignment.role) end + if source == "rake" and field.windrowOffset ~= nil and math.abs(field.windrowOffset) > 0.05 then + local own = ADCpBridge.getToolOffset(vehicle) + local target = ADFieldModel.collectorOffset(own, field.windrowOffset) + if ADCpBridge.setToolOffset(vehicle, target) then + assignment.restoreToolOffset = own + log(field.id, "skår ligger %.1f m %s for rivens linje -> læssevognens forskydning %.1f m (var %.1f m)", + math.abs(field.windrowOffset), field.windrowOffset > 0 and "til venstre" or "til højre", target, own) + end + end local handle, err = ADCpBridge.start(vehicle, point.x, point.z, function(ok, reason) if ok then assignment.phase = "working" @@ -382,6 +402,12 @@ local function follow(adEnv, assignment) log(assignment.fieldId, "Courseplay svarede ikke inden 60 s") end elseif assignment.phase == "working" then + if assignment.role == "rake" and field.windrowOffset == nil and ADFieldJobs.now - assignment.since >= ADFieldJobs.WINDROW_MEASURE_MS + and ADCpBridge.isWorking(vehicle) then + local offset = ADCpBridge.getWindrowOffset(vehicle) + note(assignment.fieldId, {windrowOffset = offset or 0}) + log(assignment.fieldId, offset ~= nil and "river lægger skåret %.1f m fra sin kursuslinje (venstre +)" or "river: nedlægningsområde ikke fundet — regner med midten (%.1f m)", offset or 0) + end local busy = ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) if busy then assignment.idleSince = nil @@ -398,6 +424,7 @@ local function follow(adEnv, assignment) log(assignment.fieldId, "%s '%s' færdig", assignment.role, ADRuns.vehicleLabel(vehicle)) local park = assignment.role ~= "collector" and parkMarkerId(adEnv, vehicle) or nil if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil, false) end + restoreToolOffset(assignment) assignment.phase = "finished" ADFieldJobs.finished = ADFieldJobs.finished or {} table.insert(ADFieldJobs.finished, assignment) diff --git a/FS25_ADSmartPickup/adFieldModel.lua b/FS25_ADSmartPickup/adFieldModel.lua index 21aa0ed..74c4462 100644 --- a/FS25_ADSmartPickup/adFieldModel.lua +++ b/FS25_ADSmartPickup/adFieldModel.lua @@ -11,6 +11,7 @@ ADFieldModel.CP_MAX_WIDTH = 50 ADFieldModel.WIDTH_TOLERANCE = 0.05 ADFieldModel.RETRY_MS = 300000 ADFieldModel.MAX_COLLECTORS = 2 +ADFieldModel.CP_MAX_TOOL_OFFSET = 10 ADFieldModel.ROLES = {mowing = "mower", tedding = "tedder", raking = "rake", collecting = "collector"} local KNOWN_STAGES = {idle = true, ready = true, mowing = true, tedding = true, raking = true, collecting = true, done = true} @@ -110,6 +111,7 @@ function ADFieldModel.start(field) copy.mowWidth = nil copy.mowVehicleId = nil copy.rakeVehicleId = nil + copy.windrowOffset = nil return copy end @@ -169,6 +171,13 @@ function ADFieldModel.with(field, changes) return copy end +-- Læssevognens Courseplay-forskydning når den kører rivens kursus: pickuppen skal ligge over skåret, der +-- ligger windrowOffset m til venstre (positiv) for rivens kursuslinje. Courseplay tillader ±10 m. +function ADFieldModel.collectorOffset(ownOffset, windrowOffset) + local offset = (ownOffset or 0) - (windrowOffset or 0) + return math.max(-ADFieldModel.CP_MAX_TOOL_OFFSET, math.min(ADFieldModel.CP_MAX_TOOL_OFFSET, offset)) +end + function ADFieldModel.toRecord(field) return { id = field.id, stage = field.stage, mode = field.plan.mode, product = field.plan.product, @@ -176,6 +185,7 @@ function ADFieldModel.toRecord(field) mowWidth = field.mowWidth or 0, mowVehicle = field.mowVehicleId or "", rakeVehicle = field.rakeVehicleId or "", + windrowOffset = field.windrowOffset ~= nil and string.format("%.2f", field.windrowOffset) or "", } end @@ -189,5 +199,6 @@ function ADFieldModel.fromRecord(record) field.mowWidth = (record.mowWidth or 0) > 0 and record.mowWidth or nil field.mowVehicleId = record.mowVehicle ~= nil and record.mowVehicle ~= "" and record.mowVehicle or nil field.rakeVehicleId = record.rakeVehicle ~= nil and record.rakeVehicle ~= "" and record.rakeVehicle or nil + field.windrowOffset = tonumber(record.windrowOffset) return field end diff --git a/FS25_ADSmartPickup/adFieldStorage.lua b/FS25_ADSmartPickup/adFieldStorage.lua index 9209aa0..979abb0 100644 --- a/FS25_ADSmartPickup/adFieldStorage.lua +++ b/FS25_ADSmartPickup/adFieldStorage.lua @@ -30,6 +30,7 @@ function ADFieldStorage.save(state) xmlFile:setFloat(key .. "#mowWidth", record.mowWidth) xmlFile:setString(key .. "#mowVehicle", record.mowVehicle) xmlFile:setString(key .. "#rakeVehicle", record.rakeVehicle) + xmlFile:setString(key .. "#windrowOffset", record.windrowOffset) index = index + 1 end local vehicleIndex = 0 @@ -55,6 +56,7 @@ function ADFieldStorage.load() rake = xmlFile:getInt(key .. "#rake"), store = xmlFile:getString(key .. "#store"), mowWidth = xmlFile:getFloat(key .. "#mowWidth"), mowVehicle = xmlFile:getString(key .. "#mowVehicle"), rakeVehicle = xmlFile:getString(key .. "#rakeVehicle"), + windrowOffset = xmlFile:getString(key .. "#windrowOffset"), }) if field.id ~= nil then result.fields[field.id] = field end end) diff --git a/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua b/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua index 5e25a29..d036d4a 100644 --- a/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua +++ b/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua @@ -111,6 +111,10 @@ function SmartPickupFrame:fillFieldDetail(adEnv, info) if field.mowWidth ~= nil then self:addTextRow(string.format(i18n:getText("spu_field_mowWidth"), field.mowWidth)) end + if field.plan.rake and field.windrowOffset ~= nil then + self:addTextRow(string.format(i18n:getText("spu_field_windrowOffset"), math.abs(field.windrowOffset), + i18n:getText(math.abs(field.windrowOffset) <= 0.05 and "spu_field_side_center" or (field.windrowOffset > 0 and "spu_field_side_left" or "spu_field_side_right")))) + end self:fillFieldStoreRows(adEnv, info, field) end diff --git a/FS25_ADSmartPickup/l10n/l10n_da.xml b/FS25_ADSmartPickup/l10n/l10n_da.xml index 8a0db00..05a6261 100644 --- a/FS25_ADSmartPickup/l10n/l10n_da.xml +++ b/FS25_ADSmartPickup/l10n/l10n_da.xml @@ -87,6 +87,10 @@ + + + + diff --git a/FS25_ADSmartPickup/l10n/l10n_en.xml b/FS25_ADSmartPickup/l10n/l10n_en.xml index 0217b8b..4b2b4d5 100644 --- a/FS25_ADSmartPickup/l10n/l10n_en.xml +++ b/FS25_ADSmartPickup/l10n/l10n_en.xml @@ -87,6 +87,10 @@ + + + + diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml index c367485..671ce6f 100644 --- a/FS25_ADSmartPickup/modDesc.xml +++ b/FS25_ADSmartPickup/modDesc.xml @@ -1,7 +1,7 @@ masterdraco - 1.23.0.0 + 1.24.0.0 <en>AD Smart Pickup</en> diff --git a/docs/BRUGERMANUAL.md b/docs/BRUGERMANUAL.md index 9002b90..6f175f0 100644 --- a/docs/BRUGERMANUAL.md +++ b/docs/BRUGERMANUAL.md @@ -1,6 +1,6 @@ # AD Smart Pickup — brugermanual -Version 1.23.0 · Farming Simulator 25 · tillæg til AutoDrive +Version 1.24.0 · Farming Simulator 25 · tillæg til AutoDrive *English version: [USER_MANUAL.md](USER_MANUAL.md)* @@ -555,7 +555,9 @@ hvilket trin og hvilken maskine. Kræver **Courseplay** ud over AutoDrive. smallere end slåmaskinen. - **Læssevognen** skal ramme skårene og kører derfor et kopieret kursus: - **uden Riv:** slåmaskinens kursus, og vognen skal være **mindst lige så bred som slåbredden**. - - **med Riv:** rivens kursus. Skåret ligger midt i hver rivebane, så vognens bredde er ligegyldig. + - **med Riv:** rivens kursus, og vognens bredde er ligegyldig. Moden **måler, hvor riven lægger + skåret** (midt i banen eller ude til siden) og forskyder læssevognen tilsvarende med Courseplays + værktøjsforskydning. Vognens egen indstilling sættes tilbage bagefter. Målingen står under marken. - Har du kun smalle standardvogne, så slå **Riv** til på marken (også ved ensilage). Med Unrealistic Pack: sæt slåmaskinen og læssevognene til samme bredde, fx 40,8 m. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index ef9ff9b..19582d5 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -1,6 +1,6 @@ # AD Smart Pickup — user manual -Version 1.23.0 · Farming Simulator 25 · add-on for AutoDrive +Version 1.24.0 · Farming Simulator 25 · add-on for AutoDrive *Dansk version: [BRUGERMANUAL.md](BRUGERMANUAL.md)* @@ -561,8 +561,10 @@ which step and which machine. Requires **Courseplay** in addition to AutoDrive. narrower than the mower. - **The loading wagon** must hit the swaths, so it drives a copied course: - **without Rake:** the mower's course, and the wagon must be **at least as wide as the mowing width**. - - **with Rake:** the rake's course. The swath lies in the middle of each rake lane, so the wagon's width - does not matter. + - **with Rake:** the rake's course, and the wagon's width does not matter. The mod **measures where the + rake drops the swath** (centre or to the side) and offsets the loading wagon to match using + Courseplay's tool offset. The wagon's own setting is restored afterwards. The measurement is shown + under the field. - If you only have narrow standard wagons, turn **Rake** on for the field (also for silage). With Unrealistic Pack: set the mower and the loading wagons to the same width, such as 40.8 m. diff --git a/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md b/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md index 5f438f3..d8a64e7 100644 --- a/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md +++ b/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md @@ -48,6 +48,10 @@ Ikke med i etape 1: mejetærskerafgrøder (etape 2), roer (etape 3), jordbehandl eget kursus (intet breddekrav). Læssevognen følger skårene: uden riv slåmaskinens kursus og bredde ≥ slåbredde; med riv rivens kursus (skåret ligger midt i banen) og intet breddekrav. Riv kan vælges til både hø og ensilage. + **Sideriver (25/9):** mens riven arbejder, måles nedlægningsområdets midte (runtime-felt + `dropWindrowWorkAreaIndex`, ellers riverens XML) i traktorens ramme minus rivens CP-forskydning = + skårets sideforskydning (gemt pr. mark). Læssevognen på rivens kursus får `toolOffsetX = egen − + skårets` (±10 m) og får sin egen værdi tilbage bagefter. 7. **Silovalg** genbruger Smart Pickups motor (`ADLoadSwap`): nærmeste silo med plads, der tager varen, med vej fra `F`-markøren. *Tving lager* pr. mark (som "Lager til" i flows). Fabrikker er ikke mål. 8. **Marksilo.** En silo, hvis afstand til markens polygon er ≤ 150 m, er et mellemlager. Den må gerne diff --git a/tests/test_adCpBridge.lua b/tests/test_adCpBridge.lua index d7f281b..01cc37c 100644 --- a/tests/test_adCpBridge.lua +++ b/tests/test_adCpBridge.lua @@ -35,5 +35,31 @@ local zeroCp = {getVariableWorkWidth = function(_, isLeft) return isLeft and 5 o getCourseGeneratorSettings = function() return {workWidth = {getValue = function() return 0 end}} end} check("W5 CP 0 -> egen beregning", ADCpBridge.getWorkWidth(zeroCp), 10) +-- WO: skårets sideforskydning på en river, målt fra nedlægningsområdet (venstre positiv), minus rivens +-- egen CP-forskydning. Noder er tal; localToLocal-mock giver nodens x i traktorens ramme. +local nodeX = {[11] = 2.0, [12] = 4.0, [21] = -1.0, [22] = 1.0} +function localToLocal(node) return nodeX[node] or 0, 0, 0 end +local function rake(dropIndex, runtime) + local areas = { + {start = 1, width = 2, height = 3, dropWindrowWorkAreaIndex = runtime and dropIndex or nil}, + {start = 10, width = 11, height = 12}, + {start = 20, width = 21, height = 22}, + } + return {spec_windrower = {}, spec_workArea = {workAreas = areas}, configFileName = "rake.xml"} +end +local sideRake = rake(2, true) +local tractorWithRake = {rootNode = 100, getAttachedImplements = function() return {{object = sideRake}} end, + getCpSettings = function() return {toolOffsetX = {getValue = function() return 0.5 end}} end} +check("WO1 sideriver (runtime-felt)", ADCpBridge.getWindrowOffset(tractorWithRake), 2.5) +local centerRake = rake(3, true) +local tractorCenter = {rootNode = 100, getAttachedImplements = function() return {{object = centerRake}} end} +check("WO2 midterrive", ADCpBridge.getWindrowOffset(tractorCenter), 0) +-- WO3: runtime-felt mangler -> riverens XML (workArea(0) windrower#dropWindrowWorkAreaIndex = 2) +ADCpBridge.readDropIndicesFromXml = function(fileName) return fileName == "rake.xml" and {[1] = 2} or {} end +local xmlRake = rake(2, false) +local tractorXml = {rootNode = 100, getAttachedImplements = function() return {{object = xmlRake}} end} +check("WO3 fra XML", ADCpBridge.getWindrowOffset(tractorXml), 3) +check("WO4 ingen river -> nil", ADCpBridge.getWindrowOffset({rootNode = 100}), nil) + print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1) diff --git a/tests/test_adFieldModel.lua b/tests/test_adFieldModel.lua index 0d9e21e..fea948d 100644 --- a/tests/test_adFieldModel.lua +++ b/tests/test_adFieldModel.lua @@ -121,5 +121,17 @@ local withRake = M.with(raked, {rakeVehicleId = "vehicleRake"}) check("M11 rivemaskine gemt", M.fromRecord(M.toRecord(withRake)).rakeVehicleId, "vehicleRake") check("M11 start glemmer rivemaskine", M.start(M.with(M.onReadiness(M.newField(9), 1), {rakeVehicleId = "x"})).rakeVehicleId, nil) +-- M12: sideriver — læssevognens CP-forskydning = egen forskydning − skårets forskydning (venstre positiv), +-- holdt inden for Courseplays ±10 m; skårets forskydning gemmes pr. mark +check("M12 midterrive", M.collectorOffset(0, 0), 0) +check("M12 skår 3 m til venstre", M.collectorOffset(0, 3), -3) +check("M12 vogn med egen forskydning", M.collectorOffset(0.5, -2), 2.5) +check("M12 holdes inden for 10 m", M.collectorOffset(0, 14), -10) +check("M12 ukendt skår -> egen forskydning", M.collectorOffset(0.4, nil), 0.4) +local side = M.with(M.newField(11), {windrowOffset = 3.2}) +check("M12 skår gemt", M.fromRecord(M.toRecord(side)).windrowOffset, 3.2) +check("M12 intet skår gemt", M.fromRecord(M.toRecord(M.newField(12))).windrowOffset, nil) +check("M12 start glemmer skår", M.start(M.with(M.onReadiness(M.newField(13), 1), {windrowOffset = 1})).windrowOffset, nil) + print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1)