-- FS25 Working Width Options - JD v1.0.0.1
-- Runtime hook is attached directly to GIANTS WorkArea.onLoad.
-- v1.0.0.1: fertilizer-spreader axis fix + dynamic mower total-width fix + safe one-time AI width cache + plow/cultivator marker safety.
-- Guide alignment hotfix: mower guides are locked to the same resize basis and actual final cutting envelope.
-- Crop coverage hotfix: mower MOWER triangles use a shared straight crop band, tiled edge-to-edge across the selected total width.
-- Mowers use only WorkAreaType.MOWER cutting areas and derive width from the
-- cutting WorkArea geometry itself (never AI markers), so 25 m / 50 m is
-- the TOTAL combined mower cutting envelope. Mower nodes are recalculated
-- after folding so a 25 m selection cannot balloon when the decks unfold.

JDWorkingWidth = JDWorkingWidth or {}
JDWorkingWidth.MOD_DIR = g_currentModDirectory or ""
JDWorkingWidth.WIDTH_CONFIG_NAME = JDWorkingWidth.WIDTH_CONFIG_NAME or "jdWorkingWidth"
JDWorkingWidth.GUIDE_CONFIG_NAME = JDWorkingWidth.GUIDE_CONFIG_NAME or "jdCropGuides"
JDWorkingWidth.LEFT_PATH = Utils.getFilename("shared/cropGuideLeft.i3d", JDWorkingWidth.MOD_DIR)
JDWorkingWidth.RIGHT_PATH = Utils.getFilename("shared/cropGuideRight.i3d", JDWorkingWidth.MOD_DIR)
JDWorkingWidth.GUIDE_Y = 0.50

JDWorkingWidth.TARGET_WIDTHS = {
    [2] = 25,
    [3] = 50
}

JDWorkingWidth.GUIDE_COLORS = {
    [1] = {0.0, 5.0, 0.0},
    [2] = {5.0, 5.0, 5.0},
    [3] = {5.0, 0.0, 0.0},
    [4] = {5.0, 2.0, 0.0},
    [5] = {5.0, 5.0, 0.0},
    [6] = {0.0, 1.0, 5.0},
    [7] = {3.0, 0.0, 5.0},
    [8] = {5.0, 0.0, 2.5}
}

local function validNode(node)
    return node ~= nil and node ~= 0 and entityExists(node)
end

local function getPos(node, root)
    if not validNode(node) or not validNode(root) then
        return nil
    end
    return localToLocal(node, root, 0, 0, 0)
end

local function setPos(node, root, x, y, z)
    if not validNode(node) or not validNode(root) then
        return false
    end
    local parent = getParent(node)
    if parent == nil or parent == 0 then
        return false
    end
    local wx, wy, wz = localToWorld(root, x, y, z)
    local lx, ly, lz = worldToLocal(parent, wx, wy, wz)
    setTranslation(node, lx, ly, lz)
    return true
end

local function addUniqueNode(nodes, node, root)
    if not validNode(node) or nodes[node] ~= nil then
        return
    end
    local x, y, z = getPos(node, root)
    if x ~= nil then
        nodes[node] = {x=x, y=y, z=z}
    end
end

-- Mowers can contain extra WorkAreas for tedding, conditioning, swathing,
-- effects, etc. Those areas must NOT be used to calculate the requested
-- cutting width. Only the real mower/cutting areas define 25 m / 50 m.
local function isMowerCutWorkArea(workArea)
    if workArea == nil then
        return false
    end

    -- FS25 Mower.loadWorkAreaFromXML converts normal mower cutting areas to
    -- WorkAreaType.MOWER. Auxiliary drop/swath areas stay AUXILIARY and must
    -- never be counted as cutting width.
    if WorkAreaType ~= nil and WorkAreaType.MOWER ~= nil then
        if workArea.type == WorkAreaType.MOWER then
            return true
        end
        if WorkAreaType.AUXILIARY ~= nil and workArea.type == WorkAreaType.AUXILIARY then
            return false
        end
    end

    local function hasMowerName(value)
        if type(value) ~= "string" then
            return false
        end
        local lower = string.lower(value)
        return string.find(lower, "mower", 1, true) ~= nil
            or string.find(lower, "mow", 1, true) ~= nil
    end

    if hasMowerName(workArea.functionName)
        or hasMowerName(workArea.typeName)
        or hasMowerName(workArea.workAreaTypeName)
        or hasMowerName(workArea.type) then
        return true
    end

    -- Common GIANTS mower XML uses functionName="processMowerArea".
    -- Some converted mods omit the string at runtime; if a mower-specific
    -- subtable exists, treat that WorkArea as a true mower area.
    if workArea.mower ~= nil or workArea.spec_mower ~= nil then
        return true
    end

    return false
end

local function getRelevantWorkAreas(self)
    local allAreas = self.spec_workArea ~= nil and self.spec_workArea.workAreas or nil
    if allAreas == nil then
        return {}, false
    end

    if self.spec_mower == nil then
        return allAreas, false
    end

    local mowerAreas = {}
    for _, workArea in ipairs(allAreas) do
        if isMowerCutWorkArea(workArea) then
            mowerAreas[#mowerAreas + 1] = workArea
        end
    end

    -- Only use the mower-only path when we can positively identify cutting areas.
    -- Falling back to all areas keeps unusual/old mower mods functional.
    if #mowerAreas > 0 then
        return mowerAreas, true
    end

    Logging.warning(
        "[JDWorkingWidth] MOWER '%s' had no tagged processMowerArea entries; using all WorkAreas as fallback",
        tostring(self.getName ~= nil and self:getName() or "unknown")
    )
    return allAreas, false
end

-- Determine the lateral working-width direction without assuming every
-- spreader authors its WorkArea triangle the same way.
-- Priority:
--   1) AI left/right markers, when they are already available.
--   2) Principal axis of the complete WorkArea point cloud for sprayers/spreaders.
--   3) GIANTS-standard WorkArea start -> width edge as a fallback.
local function normalizeAxis(dx, dz)
    local length = math.sqrt(dx * dx + dz * dz)
    if length <= 0.001 then
        return nil, nil, 0
    end

    local ux = dx / length
    local uz = dz / length

    -- Keep direction deterministic so guide left/right ordering does not flip.
    if ux < -0.0001 or (math.abs(ux) <= 0.0001 and uz < 0) then
        ux = -ux
        uz = -uz
    end

    return ux, uz, length
end

local function getAIMarkerBasis(self, root)
    if self.getAIMarkers == nil then
        return nil
    end

    local ok, leftMarker, rightMarker = pcall(self.getAIMarkers, self)
    if not ok or not validNode(leftMarker) or not validNode(rightMarker) then
        return nil
    end

    local lx, _, lz = getPos(leftMarker, root)
    local rx, _, rz = getPos(rightMarker, root)
    if lx == nil or rx == nil then
        return nil
    end

    local ux, uz, length = normalizeAxis(lx - rx, lz - rz)
    if ux == nil or length < 0.50 then
        return nil
    end

    return ux, uz
end

local function getWorkAreaPointCloud(self, root, workAreas)
    local points = {}
    local seen = {}

    for _, workArea in ipairs(workAreas or self.spec_workArea.workAreas) do
        local allNodes = {workArea.start, workArea.width, workArea.height}
        for _, node in ipairs(allNodes) do
            if validNode(node) and seen[node] == nil then
                local x, _, z = getPos(node, root)
                if x ~= nil then
                    seen[node] = true
                    table.insert(points, {x=x, z=z})
                end
            end
        end
    end

    return points
end

local function getPrincipalWorkAreaAxis(self, root, workAreas)
    local points = getWorkAreaPointCloud(self, root, workAreas)
    if #points < 3 then
        return nil
    end

    local meanX, meanZ = 0, 0
    for _, point in ipairs(points) do
        meanX = meanX + point.x
        meanZ = meanZ + point.z
    end
    meanX = meanX / #points
    meanZ = meanZ / #points

    local xx, xz, zz = 0, 0, 0
    for _, point in ipairs(points) do
        local dx = point.x - meanX
        local dz = point.z - meanZ
        xx = xx + dx * dx
        xz = xz + dx * dz
        zz = zz + dz * dz
    end

    -- Major eigenvector of the 2D covariance matrix. On fertilizer spreaders
    -- this tracks the complete spread pattern even when a mod swaps/rotates
    -- individual start/width/height nodes.
    local angle = 0.5 * math.atan2(2 * xz, xx - zz)
    local ux, uz = math.cos(angle), math.sin(angle)
    ux, uz = normalizeAxis(ux, uz)
    return ux, uz
end

local function getStandardWorkAreaAxis(self, root, workAreas)
    local bestLength = 0
    local bestUx, bestUz = nil, nil

    for _, workArea in ipairs(workAreas or self.spec_workArea.workAreas) do
        local sx, _, sz = getPos(workArea.start, root)
        local wx, _, wz = getPos(workArea.width, root)
        if sx ~= nil and wx ~= nil then
            local ux, uz, length = normalizeAxis(wx - sx, wz - sz)
            if ux ~= nil and length > bestLength then
                bestLength = length
                bestUx = ux
                bestUz = uz
            end
        end
    end

    return bestUx, bestUz
end

local function getWorkAreaBasis(self, root, workAreas)
    local ux, uz
    local source

    -- IMPORTANT: mower AI markers are not a reliable description of the mower
    -- cutting-width axis. Multi-deck/self-propelled mowers can place AI markers
    -- on a steering/course axis instead. Always derive mower width from the
    -- actual MOWER WorkArea start->width geometry.
    if self.spec_mower ~= nil then
        -- Multi-deck mowers can have several folding components.  The longest
        -- single start->width edge is not always the true left/right axis once
        -- the decks are unfolded.  Use the complete current mower point cloud
        -- first, then fall back to the authored start->width edge.
        ux, uz = getPrincipalWorkAreaAxis(self, root, workAreas)
        source = "MOWER_COMBINED_PCA"
        if ux == nil then
            ux, uz = getStandardWorkAreaAxis(self, root, workAreas)
            source = "MOWER_START_WIDTH"
        end
    else
        ux, uz = getAIMarkerBasis(self, root)
        source = "AI_MARKERS"

        -- Fertilizer spreaders and sprayers are the main non-mower problem case:
        -- some mods build WorkArea triangles with different node orientations.
        if ux == nil and self.spec_sprayer ~= nil then
            ux, uz = getPrincipalWorkAreaAxis(self, root, workAreas)
            source = "WORKAREA_PCA"
        end

        if ux == nil then
            ux, uz = getStandardWorkAreaAxis(self, root, workAreas)
            source = "START_WIDTH"
        end
    end

    if ux == nil then
        ux, uz = 1, 0
        source = "ROOT_X_FALLBACK"
    end

    -- Perpendicular longitudinal axis in the component X/Z plane.
    local vx = -uz
    local vz = ux
    return ux, uz, vx, vz, source
end
local function getWorkAreaEnvelope(self, root, workAreas, fixedBasis)
    local ux, uz, vx, vz, axisSource
    if fixedBasis ~= nil and fixedBasis.ux ~= nil and fixedBasis.uz ~= nil then
        ux = fixedBasis.ux
        uz = fixedBasis.uz
        vx = fixedBasis.vx or -uz
        vz = fixedBasis.vz or ux
        axisSource = fixedBasis.axisSource or "LOCKED_BASIS"
    else
        ux, uz, vx, vz, axisSource = getWorkAreaBasis(self, root, workAreas)
    end
    local minP, maxP = math.huge, -math.huge
    local qTotal, qCount = 0, 0
    local nodeCount = 0
    local seen = {}

    -- Use all three authored WorkArea nodes plus the inferred fourth corner.
    -- Mower processing operates on the complete parallelogram, so including the
    -- fourth corner prevents the reported/guide width from being smaller than
    -- the area that can actually cut.
    local function addPoint(x, z)
        if x == nil then
            return
        end
        local p = x * ux + z * uz
        local q = x * vx + z * vz
        minP = math.min(minP, p)
        maxP = math.max(maxP, p)
        qTotal = qTotal + q
        qCount = qCount + 1
        nodeCount = nodeCount + 1
    end

    for _, workArea in ipairs(workAreas or self.spec_workArea.workAreas) do
        local sx, _, sz = getPos(workArea.start, root)
        local wx, _, wz = getPos(workArea.width, root)
        local hx, _, hz = getPos(workArea.height, root)

        addPoint(sx, sz)
        addPoint(wx, wz)
        addPoint(hx, hz)

        if sx ~= nil and wx ~= nil and hx ~= nil then
            -- Fourth corner of the WorkArea parallelogram.
            addPoint(wx + hx - sx, wz + hz - sz)
        end
    end

    if nodeCount == 0 or minP == math.huge or maxP == -math.huge then
        return nil
    end

    local centerP = (minP + maxP) * 0.5
    local centerQ = qCount > 0 and (qTotal / qCount) or 0

    return {
        ux = ux, uz = uz,
        vx = vx, vz = vz,
        minP = minP, maxP = maxP,
        centerP = centerP,
        centerQ = centerQ,
        width = maxP - minP,
        axisSource = axisSource
    }
end

-- Some multi-section mowers can report a 25/50 m combined envelope while the
-- actual MOWER triangles still leave gaps between deck WorkAreas. GIANTS cuts
-- crop directly from each workArea.start/width/height triangle, so tile those
-- true MOWER areas edge-to-edge across the selected TOTAL width. This changes
-- only the lateral P coordinate; each area's longitudinal Q position is kept.
-- Exact geometry fix for the uploaded FS25_KroneBiG_M500 / "BiG M 500 by Power".
-- This mower has three cutting WorkAreas mounted on three different moving
-- components.  Stretching those original nodes can make the three density-map
-- sweeps start/end at different longitudinal positions, producing a stepped
-- crop edge.  For 25 m / 50 m we replace ONLY the three cutter node references
-- with three root-linked rectangular slices that share the exact same front and
-- rear lines.  The three original workArea tables (and their individual drop
-- area indices) remain intact.
local function isPowerBigM500(self)
    if self == nil or self.spec_mower == nil then
        return false
    end

    local configName = string.lower(tostring(self.configFileName or ""))
    if string.find(configName, "fs25_kronebig_m500", 1, true) ~= nil then
        return true
    end

    local vehicleName = ""
    if self.getName ~= nil then
        local ok, name = pcall(self.getName, self)
        if ok and name ~= nil then
            vehicleName = string.lower(tostring(name))
        end
    end

    return string.find(vehicleName, "big m 500 by power", 1, true) ~= nil
end

local function ensureBigMStraightNode(self, root, key)
    self.jdWorkingWidthBigMStraightNodes = self.jdWorkingWidthBigMStraightNodes or {}
    local node = self.jdWorkingWidthBigMStraightNodes[key]
    if validNode(node) then
        return node
    end

    if createTransformGroup == nil then
        return nil
    end

    node = createTransformGroup("jdBigM500Straight_" .. tostring(key))
    if not validNode(node) then
        return nil
    end

    link(root, node)
    self.jdWorkingWidthBigMStraightNodes[key] = node
    return node
end

local function setRootLocalNode(node, x, y, z)
    if not validNode(node) then
        return false
    end
    setTranslation(node, x, y, z)
    return true
end

local function forcePowerBigM500StraightCut(self, root, targetWidth, workAreas)
    if not isPowerBigM500(self) or targetWidth == nil or targetWidth <= 0 then
        return false, nil
    end
    if workAreas == nil or #workAreas < 1 then
        return false, nil
    end

    local data = self.jdWorkingWidthBigMStraightData
    if data == nil then
        -- For this mower, start->width is the authored lateral cutting edge on
        -- all three decks.  Use that directly instead of PCA.
        local ux, uz = getStandardWorkAreaAxis(self, root, workAreas)
        if ux == nil then
            local fallback = getWorkAreaEnvelope(self, root, workAreas)
            if fallback == nil then
                return false, nil
            end
            ux, uz = fallback.ux, fallback.uz
        end
        local vx, vz = -uz, ux

        local sections = {}
        local minP, maxP = math.huge, -math.huge
        local qCenterTotal, qCenterCount = 0, 0
        local depthValues = {}

        for index, workArea in ipairs(workAreas) do
            local sx, _, sz = getPos(workArea.start, root)
            local wx, _, wz = getPos(workArea.width, root)
            local hx, _, hz = getPos(workArea.height, root)
            if sx ~= nil and wx ~= nil and hx ~= nil then
                local ps = sx * ux + sz * uz
                local pw = wx * ux + wz * uz
                local ph = hx * ux + hz * uz
                local qs = sx * vx + sz * vz
                local qw = wx * vx + wz * vz
                local qh = hx * vx + hz * vz
                local fourthP = pw + ph - ps
                local fourthQ = qw + qh - qs
                local areaMinP = math.min(ps, pw, ph, fourthP)
                local areaMaxP = math.max(ps, pw, ph, fourthP)
                local areaMinQ = math.min(qs, qw, qh, fourthQ)
                local areaMaxQ = math.max(qs, qw, qh, fourthQ)

                minP = math.min(minP, areaMinP)
                maxP = math.max(maxP, areaMaxP)
                qCenterTotal = qCenterTotal + (areaMinQ + areaMaxQ) * 0.5
                qCenterCount = qCenterCount + 1
                depthValues[#depthValues + 1] = math.max(areaMaxQ - areaMinQ, 0.35)

                sections[#sections + 1] = {
                    workArea = workArea,
                    originalIndex = index,
                    centerP = (areaMinP + areaMaxP) * 0.5,
                    startBeforeWidth = ps <= pw
                }
            end
        end

        if #sections == 0 or minP == math.huge then
            return false, nil
        end

        table.sort(sections, function(a, b)
            return a.centerP < b.centerP
        end)
        table.sort(depthValues)

        local centerP = (minP + maxP) * 0.5
        local centerQ = qCenterCount > 0 and (qCenterTotal / qCenterCount) or 0
        local depth = 1.0
        if #depthValues > 0 then
            local middle = math.floor((#depthValues + 1) * 0.5)
            depth = math.max(depthValues[middle] or 1.0, 0.75)
        end
        -- Keep the synthetic sweep modest so the visual mower position and cut
        -- position stay close, while still giving plenty of density-map overlap.
        depth = math.min(math.max(depth, 0.85), 1.25)

        data = {
            ux = ux, uz = uz,
            vx = vx, vz = vz,
            centerP = centerP,
            centerQ = centerQ,
            depth = depth,
            sections = sections
        }
        self.jdWorkingWidthBigMStraightData = data
    end

    local count = #data.sections
    if count == 0 then
        return false, nil
    end

    local targetMin = data.centerP - targetWidth * 0.5
    local targetMax = data.centerP + targetWidth * 0.5
    local step = targetWidth / count
    local overlap = 0.06
    local frontQ = data.centerQ + data.depth * 0.5
    local rearQ = data.centerQ - data.depth * 0.5

    local function toRootXZ(p, q)
        return data.ux * p + data.vx * q, data.uz * p + data.vz * q
    end

    for i, section in ipairs(data.sections) do
        local lowP = targetMin + (i - 1) * step
        local highP = targetMin + i * step
        if i > 1 then
            lowP = lowP - overlap
        end
        if i < count then
            highP = highP + overlap
        end

        local startP, widthP
        if section.startBeforeWidth then
            startP, widthP = lowP, highP
        else
            startP, widthP = highP, lowP
        end

        local startNode = ensureBigMStraightNode(self, root, "s" .. tostring(i) .. "Start")
        local widthNode = ensureBigMStraightNode(self, root, "s" .. tostring(i) .. "Width")
        local heightNode = ensureBigMStraightNode(self, root, "s" .. tostring(i) .. "Height")
        if not validNode(startNode) or not validNode(widthNode) or not validNode(heightNode) then
            return false, nil
        end

        local sx, sz = toRootXZ(startP, frontQ)
        local wx, wz = toRootXZ(widthP, frontQ)
        local hx, hz = toRootXZ(startP, rearQ)
        setRootLocalNode(startNode, sx, 0, sz)
        setRootLocalNode(widthNode, wx, 0, wz)
        setRootLocalNode(heightNode, hx, 0, hz)

        -- Repoint the real mower WorkArea to the rigid root-linked rectangle.
        -- This is the key difference: folding deck parent transforms can no
        -- longer stagger the crop line from one section to the next.
        section.workArea.start = startNode
        section.workArea.width = widthNode
        section.workArea.height = heightNode
    end

    if self.updateWorkAreaWidth ~= nil and self.spec_workArea ~= nil and self.spec_workArea.workAreas ~= nil then
        for i = 1, #self.spec_workArea.workAreas do
            self:updateWorkAreaWidth(i)
        end
    end

    self.jdWorkingWidthMowerGuideBasis = {
        ux = data.ux, uz = data.uz,
        vx = data.vx, vz = data.vz,
        axisSource = "BIGM500_RIGID_STRAIGHT"
    }
    self.jdWorkingWidthCurrentWidth = targetWidth

    Logging.info(
        "[JDWorkingWidth] BIG M 500 RIGID STRAIGHT CUT '%s' sections=%d target=%.2f depth=%.2f",
        tostring(self.getName ~= nil and self:getName() or "unknown"),
        count, targetWidth, data.depth
    )

    return true, targetWidth
end

local function forceMowerCutCoverage(self, root, targetWidth, workAreas, basis)
    if self == nil or self.spec_mower == nil or targetWidth == nil or targetWidth <= 0 then
        return false
    end
    if workAreas == nil or #workAreas == 0 then
        return false
    end

    local fixedBasis = basis or self.jdWorkingWidthMowerGuideBasis
    local envelope = getWorkAreaEnvelope(self, root, workAreas, fixedBasis)
    if envelope == nil then
        return false
    end

    local ux, uz = envelope.ux, envelope.uz
    local vx, vz = envelope.vx, envelope.vz
    local centerP = envelope.centerP
    local targetMin = centerP - targetWidth * 0.5
    local targetMax = centerP + targetWidth * 0.5

    local areas = {}
    local qCenterTotal = 0
    local qCenterCount = 0
    local depthSamples = {}
    local depthDirection = 0
    local frontLineQs = {}
    local rearLineQs = {}

    for _, workArea in ipairs(workAreas) do
        local sx, sy, sz = getPos(workArea.start, root)
        local wx, wy, wz = getPos(workArea.width, root)
        local hx, hy, hz = getPos(workArea.height, root)
        if sx ~= nil and wx ~= nil and hx ~= nil then
            local ps = sx * ux + sz * uz
            local pw = wx * ux + wz * uz
            local ph = hx * ux + hz * uz
            local qs = sx * vx + sz * vz
            local qw = wx * vx + wz * vz
            local qh = hx * vx + hz * vz

            local fourthP = pw + ph - ps
            local fourthQ = qw + qh - qs
            local areaMin = math.min(ps, pw, ph, fourthP)
            local areaMax = math.max(ps, pw, ph, fourthP)

            -- Treat the start/width edge as one crop line and the height/fourth
            -- edge as the opposite crop line. We will align the actual FRONT edge
            -- across all decks, not blindly the same authored edge name.
            local startLineQ = (qs + qw) * 0.5
            local heightLineQ = (qh + fourthQ) * 0.5

            -- GIANTS WorkArea geometry: start -> width is the lateral edge and
            -- start -> height is the travel/depth edge.
            local authoredStartQ = startLineQ
            local authoredDepth = heightLineQ - startLineQ
            local absDepth = math.abs(authoredDepth)
            if absDepth > 0.02 then
                depthSamples[#depthSamples + 1] = absDepth
                depthDirection = depthDirection + (authoredDepth >= 0 and 1 or -1)
            end

            local localCenterQ = authoredStartQ + authoredDepth * 0.5
            qCenterTotal = qCenterTotal + localCenterQ
            qCenterCount = qCenterCount + 1

            areas[#areas + 1] = {
                workArea = workArea,
                center = (areaMin + areaMax) * 0.5,
                startP = ps,
                widthP = pw,
                startLineQ = startLineQ,
                heightLineQ = heightLineQ,
                authoredDepth = authoredDepth
            }
        end
    end

    if #areas == 0 then
        return false
    end

    table.sort(areas, function(a, b)
        return a.center < b.center
    end)

    -- Build contiguous lateral sections across exactly the requested TOTAL width.
    local boundaries = {}
    boundaries[1] = targetMin
    for i = 1, #areas - 1 do
        local mid = (areas[i].center + areas[i + 1].center) * 0.5
        boundaries[i + 1] = math.max(targetMin, math.min(targetMax, mid))
    end
    boundaries[#areas + 1] = targetMax

    -- Give adjacent sections a very small overlap so density-map rasterization
    -- cannot leave a one-cell strip between mower decks.
    local overlap = 0.12

    -- All mower sections now share ONE front and ONE rear crop boundary.
    -- Use the median FRONT line and median REAR line so mixed front/rear deck
    -- authoring still produces one straight crop edge across the full width.
    table.sort(depthSamples)
    local commonDepth = 0.8
    if #depthSamples > 0 then
        local middle = math.floor((#depthSamples + 1) * 0.5)
        commonDepth = math.max(depthSamples[middle] or 0.8, 0.35)
    end
    -- Determine machine-forward in the same longitudinal Q basis. In root-local
    -- coordinates, +Z is forward, so the Q basis points forward when vz >= 0.
    local forwardPositive = vz >= 0

    local function median(values)
        if values == nil or #values == 0 then
            return nil
        end
        table.sort(values)
        local n = #values
        local mid = math.floor((n + 1) * 0.5)
        if n % 2 == 1 then
            return values[mid]
        end
        return (values[mid] + values[mid + 1]) * 0.5
    end

    for _, data in ipairs(areas) do
        local frontQ
        local rearQ
        local frontIsHeight
        if forwardPositive then
            frontIsHeight = data.heightLineQ >= data.startLineQ
            frontQ = math.max(data.startLineQ, data.heightLineQ)
            rearQ = math.min(data.startLineQ, data.heightLineQ)
        else
            frontIsHeight = data.heightLineQ <= data.startLineQ
            frontQ = math.min(data.startLineQ, data.heightLineQ)
            rearQ = math.max(data.startLineQ, data.heightLineQ)
        end
        frontLineQs[#frontLineQs + 1] = frontQ
        rearLineQs[#rearLineQs + 1] = rearQ
        data.frontIsHeight = frontIsHeight
    end

    local commonFrontQ = median(frontLineQs)
    local commonRearQ = median(rearLineQs)

    if commonFrontQ == nil or commonRearQ == nil then
        local commonCenterQ = qCenterCount > 0 and (qCenterTotal / qCenterCount) or envelope.centerQ
        if forwardPositive then
            commonFrontQ = commonCenterQ + commonDepth * 0.5
            commonRearQ = commonCenterQ - commonDepth * 0.5
        else
            commonFrontQ = commonCenterQ - commonDepth * 0.5
            commonRearQ = commonCenterQ + commonDepth * 0.5
        end
    else
        commonDepth = math.max(math.abs(commonFrontQ - commonRearQ), 0.35)
    end

    local function moveNodeToPQ(node, wantedP, wantedQ)
        local x, y, z = getPos(node, root)
        if x == nil then
            return false
        end
        local nx = ux * wantedP + vx * wantedQ
        local nz = uz * wantedP + vz * wantedQ
        return setPos(node, root, nx, y, nz)
    end

    for i, data in ipairs(areas) do
        local sectionLowP = boundaries[i]
        local sectionHighP = boundaries[i + 1]
        if i > 1 then
            sectionLowP = math.max(targetMin, sectionLowP - overlap)
        end
        if i < #areas then
            sectionHighP = math.min(targetMax, sectionHighP + overlap)
        end

        local startSideP, widthSideP
        if data.startP <= data.widthP then
            startSideP, widthSideP = sectionLowP, sectionHighP
        else
            startSideP, widthSideP = sectionHighP, sectionLowP
        end

        local workArea = data.workArea
        local startWidthQ = data.frontIsHeight and commonRearQ or commonFrontQ
        local heightQ = data.frontIsHeight and commonFrontQ or commonRearQ
        moveNodeToPQ(workArea.start,  startSideP, startWidthQ)
        moveNodeToPQ(workArea.width,  widthSideP, startWidthQ)
        moveNodeToPQ(workArea.height, startSideP, heightQ)
    end

    if self.updateWorkAreaWidth ~= nil and self.spec_workArea ~= nil and self.spec_workArea.workAreas ~= nil then
        for i = 1, #self.spec_workArea.workAreas do
            self:updateWorkAreaWidth(i)
        end
    end

    local finalEnvelope = getWorkAreaEnvelope(self, root, workAreas, fixedBasis)
    if finalEnvelope ~= nil then
        self.jdWorkingWidthCurrentWidth = finalEnvelope.width
        Logging.info(
            "[JDWorkingWidth] MOWER STRAIGHT FRONT EDGE '%s' sections=%d target=%.2f final=%.2f depth=%.2f",
            tostring(self.getName ~= nil and self:getName() or "unknown"),
            #areas, targetWidth, finalEnvelope.width, commonDepth
        )
    end

    return true
end

local function resizeWorkAreas(self, root, targetWidth, workAreas, updateAIMarkers, silent)
    local envelope = getWorkAreaEnvelope(self, root, workAreas)
    if envelope == nil or envelope.width < 0.01 then
        return false, 0
    end

    -- Exact uploaded BiG M 500 fix: use three rigid root-linked rectangular
    -- cutter slices so the crop edge is one straight line across the full width.
    if isPowerBigM500(self) then
        local oldWidth = envelope.width
        local success, finalWidth = forcePowerBigM500StraightCut(self, root, targetWidth, workAreas)
        if success then
            return true, oldWidth, finalWidth or targetWidth
        end
        Logging.warning("[JDWorkingWidth] BIG M 500 rigid straight-cut fallback engaged")
    end

    local nodes = {}
    for _, workArea in ipairs(workAreas or self.spec_workArea.workAreas) do
        addUniqueNode(nodes, workArea.start, root)
        addUniqueNode(nodes, workArea.width, root)
        addUniqueNode(nodes, workArea.height, root)
    end

    local oldWidth = envelope.width
    local factor = targetWidth / oldWidth

    -- IMPORTANT: keep the exact lateral basis that was used for this resize.
    -- PCA can rotate slightly after the WorkArea nodes are stretched, which used
    -- to make mower guides drift even though the cutting area itself was correct.
    local resizeBasis = {
        ux = envelope.ux, uz = envelope.uz,
        vx = envelope.vx, vz = envelope.vz,
        axisSource = envelope.axisSource
    }
    if self.spec_mower ~= nil then
        self.jdWorkingWidthMowerGuideBasis = resizeBasis
    end

    -- Scale only along the WorkArea's actual lateral axis. Longitudinal position
    -- remains untouched, even when the vehicle component itself is rotated.
    for node, pos in pairs(nodes) do
        local p = pos.x * envelope.ux + pos.z * envelope.uz
        local newP = envelope.centerP + (p - envelope.centerP) * factor
        local delta = newP - p
        setPos(
            node,
            root,
            pos.x + envelope.ux * delta,
            pos.y,
            pos.z + envelope.uz * delta
        )
    end

    if self.updateWorkAreaWidth ~= nil then
        for i = 1, #self.spec_workArea.workAreas do
            self:updateWorkAreaWidth(i)
        end
    end

    -- On mowers, make the actual crop-cut triangles cover the full selected
    -- total width with no gaps between mower sections.
    if self.spec_mower ~= nil and not isPowerBigM500(self) then
        forceMowerCutCoverage(self, root, targetWidth, workAreas, resizeBasis)
    end

    -- Keep AI markers on the same true lateral axis as the resized WorkAreas.
    -- Mowers are intentionally excluded from this path: their AI markers can
    -- be course/steering markers rather than the actual cutting-width edge.
    if updateAIMarkers ~= false and self.getAIMarkers ~= nil then
        local leftMarker, rightMarker = self:getAIMarkers()
        if validNode(leftMarker) and validNode(rightMarker) then
            local lx, ly, lz = getPos(leftMarker, root)
            local rx, ry, rz = getPos(rightMarker, root)
            if lx ~= nil and rx ~= nil then
                local lp = lx * envelope.ux + lz * envelope.uz
                local rp = rx * envelope.ux + rz * envelope.uz
                local half = targetWidth * 0.5
                local leftTargetP, rightTargetP

                if lp >= rp then
                    leftTargetP = envelope.centerP + half
                    rightTargetP = envelope.centerP - half
                else
                    leftTargetP = envelope.centerP - half
                    rightTargetP = envelope.centerP + half
                end

                local leftDelta = leftTargetP - lp
                local rightDelta = rightTargetP - rp
                setPos(leftMarker, root, lx + envelope.ux * leftDelta, ly, lz + envelope.uz * leftDelta)
                setPos(rightMarker, root, rx + envelope.ux * rightDelta, ry, rz + envelope.uz * rightDelta)

                if self.updateAIMarkerWidth ~= nil then
                    self:updateAIMarkerWidth()
                end
            end
        end
    end

    -- Measure the final width on the SAME axis used for resizing.  Re-running
    -- PCA here can choose a slightly different axis on multi-deck mowers.
    local finalEnvelope = getWorkAreaEnvelope(self, root, workAreas, resizeBasis)
    local finalWidth = finalEnvelope ~= nil and finalEnvelope.width or targetWidth

    if self.spec_mower ~= nil and not silent then
        Logging.info(
            "[JDWorkingWidth] MOWER TOTAL WIDTH '%s' old=%.2f target=%.2f final=%.2f axis=%s",
            tostring(self.getName ~= nil and self:getName() or "unknown"),
            oldWidth, targetWidth, finalWidth,
            tostring(finalEnvelope ~= nil and finalEnvelope.axisSource or "unknown")
        )
    end

    return true, oldWidth, finalWidth
end

-- Safely synchronize the cached AI width ONCE after the selected working width
-- is initialized.  Do not wrap GIANTS AI getters and do not force Steering
-- Assist/course updates from onUpdate: doing that repeatedly can cause the F1
-- action list to rebuild, steering input to be interrupted, and the vehicle to
-- feel locked.  Non-mower implements already have their physical AI markers
-- resized above; mowers keep their marker geometry untouched and only receive
-- the cached display/planning width here.
local function applySafeAIWidthCache(self)
    if self == nil then
        return
    end

    local targetWidth = self.jdWorkingWidthTargetWidth
    if targetWidth == nil or targetWidth <= 0 then
        self.jdWorkingWidthAIReportedWidth = nil
        return
    end

    if self.jdWorkingWidthAIReportedWidth == targetWidth then
        return
    end

    if self.spec_aiImplement ~= nil then
        self.spec_aiImplement.aiMarkerWidth = targetWidth

        -- Some vehicles cache marker width per AI base setup. Update the cache
        -- once, but never touch the actual marker nodes here.
        if self.spec_aiImplement.aiBaseSetups ~= nil then
            for _, aiBaseSetup in ipairs(self.spec_aiImplement.aiBaseSetups) do
                if aiBaseSetup ~= nil then
                    aiBaseSetup.aiMarkerWidth = targetWidth
                end
            end
        end
    end

    self.jdWorkingWidthAIReportedWidth = targetWidth
    Logging.info(
        "[JDWorkingWidth] SAFE AI WIDTH CACHE '%s' -> %.0f m",
        tostring(self.getName ~= nil and self:getName() or "unknown"),
        targetWidth
    )
end

-- Mower work-area nodes are commonly children of folding deck components.
-- Resizing them only once during vehicle load can be badly wrong after the
-- decks unfold because those parents rotate afterwards.  Keep the untouched
-- parent-local translations so the mower can be restored and recalculated in
-- its CURRENT unfolded pose immediately before work-area processing.
local function captureMowerOriginalNodes(self, workAreas)
    if self.jdWorkingWidthMowerOriginalNodes ~= nil then
        return
    end

    local originals = {}
    for _, workArea in ipairs(workAreas or {}) do
        for _, node in ipairs({workArea.start, workArea.width, workArea.height}) do
            if validNode(node) and originals[node] == nil then
                local parent = getParent(node)
                if validNode(parent) then
                    local x, y, z = localToLocal(node, parent, 0, 0, 0)
                    originals[node] = {parent=parent, x=x, y=y, z=z}
                end
            end
        end
    end

    self.jdWorkingWidthMowerOriginalNodes = originals
end

local function restoreMowerOriginalNodes(self)
    local originals = self.jdWorkingWidthMowerOriginalNodes
    if originals == nil then
        return
    end

    for node, data in pairs(originals) do
        if validNode(node) and validNode(data.parent) and getParent(node) == data.parent then
            setTranslation(node, data.x, data.y, data.z)
        end
    end
end

local function getGuidePlacement(self, root, forcedWidth, workAreas)
    -- For mowers, use the exact basis that resized the cutting WorkAreas.
    -- Do not run PCA a second time after stretching the decks because that can
    -- rotate the guide axis away from the real cutting edge.
    local fixedBasis = nil
    if self.spec_mower ~= nil then
        fixedBasis = self.jdWorkingWidthMowerGuideBasis
    end

    local envelope = getWorkAreaEnvelope(self, root, workAreas, fixedBasis)
    if envelope == nil then
        return nil
    end

    local plusP
    local minusP
    local guideWidth

    if self.spec_mower ~= nil then
        -- Mower guides follow the ACTUAL final cutting envelope, edge-for-edge.
        -- This keeps 25 m / 50 m guide arrows on the same geometry that cuts.
        plusP = envelope.maxP
        minusP = envelope.minP
        guideWidth = envelope.width
    else
        guideWidth = envelope.width
        if forcedWidth ~= nil and forcedWidth > 0 then
            guideWidth = forcedWidth
        end
        local half = guideWidth * 0.5
        plusP = envelope.centerP + half
        minusP = envelope.centerP - half
    end

    local q = envelope.centerQ

    -- Convert lateral/longitudinal basis coordinates back into root-local X/Z.
    local plusX = envelope.ux * plusP + envelope.vx * q
    local plusZ = envelope.uz * plusP + envelope.vz * q
    local minusX = envelope.ux * minusP + envelope.vx * q
    local minusZ = envelope.uz * minusP + envelope.vz * q

    -- The guide meshes were authored for +X being the width axis. Rotate them so
    -- their X axis follows the exact WorkArea lateral direction used for resizing.
    local yaw = math.atan2(-envelope.uz, envelope.ux)

    return plusX, plusZ, minusX, minusZ, guideWidth, yaw, envelope
end

local function setGuideColor(node, color)
    if validNode(node) and color ~= nil then
        setShaderParameterRecursive(node, "colorScale", color[1], color[2], color[3], 0, false)
    end
end

local function findNamedNode(node, wantedName)
    if not validNode(node) then
        return nil
    end
    if getName(node) == wantedName then
        return node
    end
    local count = getNumOfChildren(node)
    for i = 0, count - 1 do
        local found = findNamedNode(getChildAt(node, i), wantedName)
        if found ~= nil then
            return found
        end
    end
    return nil
end

local function applyGuideTransform(node, root, x, y, z, yaw)
    if not validNode(node) then
        return
    end
    setPos(node, root, x, y, z)

    -- These generated guide nodes are linked directly to component[1].node.
    -- Keep a safe parent check before applying the local yaw.
    if getParent(node) == root then
        setRotation(node, 0, yaw or 0, 0)
    end
end

local function loadGuide(self, path, nodeName, x, y, z, yaw, color)
    local i3dRoot = loadI3DFile(path, false, false, true)
    if i3dRoot == nil or i3dRoot == 0 then
        Logging.warning("[JDWorkingWidth] GUIDE LOAD FAILED '%s' path='%s'", nodeName, tostring(path))
        return nil
    end

    local node = getChildAt(i3dRoot, 0)
    if node == nil or node == 0 then
        delete(i3dRoot)
        Logging.warning("[JDWorkingWidth] GUIDE I3D EMPTY '%s' path='%s'", nodeName, tostring(path))
        return nil
    end

    local root = self.components[1].node
    link(root, node)
    delete(i3dRoot)
    setName(node, nodeName)
    setTranslation(node, x, y, z)
    setRotation(node, 0, yaw or 0, 0)
    setGuideColor(node, color)
    setVisibility(node, true)
    return node
end

local function applyWorkingWidthAndGuides(self)
    if self.spec_workArea == nil or self.spec_workArea.workAreas == nil or #self.spec_workArea.workAreas == 0 then
        return
    end
    if self.components == nil or self.components[1] == nil then
        return
    end

    local root = self.components[1].node
    if not validNode(root) then
        return
    end

    local activeWorkAreas, mowerCutOnly = getRelevantWorkAreas(self)
    if activeWorkAreas == nil or #activeWorkAreas == 0 then
        return
    end

    self.jdWorkingWidthIsMower = self.spec_mower ~= nil
    self.jdWorkingWidthUsesMowerCutAreas = mowerCutOnly
    self.jdWorkingWidthAreaCount = #activeWorkAreas

    if self.jdWorkingWidthIsMower then
        Logging.info(
            "[JDWorkingWidth] MOWER CUT AREAS '%s' using=%d total=%d filtered=%s",
            tostring(self:getName()), #activeWorkAreas, #self.spec_workArea.workAreas, tostring(mowerCutOnly)
        )
    end

    local configurations = self.configurations or {}
    local widthId = configurations[JDWorkingWidth.WIDTH_CONFIG_NAME] or 1
    local targetWidth = JDWorkingWidth.TARGET_WIDTHS[widthId]
    self.jdWorkingWidthTargetWidth = targetWidth

    if targetWidth ~= nil then
        if self.jdWorkingWidthIsMower then
            captureMowerOriginalNodes(self, activeWorkAreas)
        end

        -- Plows/cultivators can use AI marker transforms that live inside folding,
        -- hitch or moving-part hierarchies. Moving those marker nodes to 25/50 m can
        -- disturb attachment/power detection on some mods. For these implements we
        -- resize only the real WorkAreas and leave physical AI marker transforms
        -- untouched; the one-time AI width cache below reports the selected width.
        local isPlowLike = self.spec_plow ~= nil or self.spec_cultivator ~= nil
        local shouldMoveAIMarkers = not self.jdWorkingWidthIsMower and not isPlowLike

        local success, oldWidth, finalWidth = resizeWorkAreas(
            self, root, targetWidth, activeWorkAreas, shouldMoveAIMarkers, false
        )
        if success then
            if isPlowLike then
                Logging.info("[JDWorkingWidth] PLOW/CULTIVATOR SAFE MODE '%s' - WorkAreas resized, AI marker transforms preserved", tostring(self:getName()))
            end
            Logging.info("[JDWorkingWidth] WIDTH APPLIED '%s' %.2f -> %.0f m%s", tostring(self:getName()), oldWidth, targetWidth, self.jdWorkingWidthIsMower and " (TOTAL mower cut envelope)" or "")
            self.jdWorkingWidthCurrentWidth = finalWidth or targetWidth
        else
            Logging.warning("[JDWorkingWidth] WIDTH FAILED '%s' target=%.0f", tostring(self:getName()), targetWidth)
            targetWidth = nil
            self.jdWorkingWidthTargetWidth = nil
        end
    else
        if self.jdWorkingWidthIsMower then
            self.jdWorkingWidthMowerGuideBasis = nil
        end
        Logging.info("[JDWorkingWidth] DEFAULT WIDTH '%s'", tostring(self:getName()))
        local placement = {getGuidePlacement(self, root, nil, activeWorkAreas)}
        self.jdWorkingWidthCurrentWidth = placement[5] or 0
    end

    -- Update only the cached AI width once. Never force Steering Assist or
    -- rebuild action events while driving.
    applySafeAIWidthCache(self)

    if not self.isClient then
        return
    end

    local colorId = configurations[JDWorkingWidth.GUIDE_CONFIG_NAME] or 1
    local color = JDWorkingWidth.GUIDE_COLORS[colorId] or JDWorkingWidth.GUIDE_COLORS[1]
    local plusX, plusZ, minusX, minusZ, guideWidth, guideYaw, envelope = getGuidePlacement(self, root, targetWidth, activeWorkAreas)
    if guideWidth == nil or guideWidth <= 0.01 then
        Logging.warning("[JDWorkingWidth] GUIDE WIDTH FAILED '%s'", tostring(self:getName()))
        return
    end

    -- Use dedicated node names so this mod can toggle its own guide lines without conflicts.
    local left = findNamedNode(root, "jdWidthGuideLeft")
    local right = findNamedNode(root, "jdWidthGuideRight")

    if left == nil then
        left = loadGuide(self, JDWorkingWidth.LEFT_PATH, "jdWidthGuideLeft", plusX, JDWorkingWidth.GUIDE_Y, plusZ, guideYaw, color)
    else
        applyGuideTransform(left, root, plusX, JDWorkingWidth.GUIDE_Y, plusZ, guideYaw)
        setGuideColor(left, color)
    end

    if right == nil then
        right = loadGuide(self, JDWorkingWidth.RIGHT_PATH, "jdWidthGuideRight", minusX, JDWorkingWidth.GUIDE_Y, minusZ, guideYaw, color)
    else
        applyGuideTransform(right, root, minusX, JDWorkingWidth.GUIDE_Y, minusZ, guideYaw)
        setGuideColor(right, color)
    end

    self.jdWorkingWidthGuideLeft = left
    self.jdWorkingWidthGuideRight = right
    self.jdWorkingWidthGuideColorId = colorId
    self.jdWorkingWidthGuideColorName = ({'Green','White','Red','Orange','Yellow','Blue','Purple','Pink'})[colorId] or 'Green'
    self.jdWorkingWidthGuidesVisible = (left ~= nil and getVisibility(left)) or (right ~= nil and getVisibility(right)) or false
    self.jdWorkingWidthHasGuides = left ~= nil or right ~= nil

    if left ~= nil or right ~= nil then
        Logging.info(
            "[JDWorkingWidth] GUIDES READY '%s' width=%.2f axis=(%.3f,%.3f) source=%s colorId=%d",
            tostring(self:getName()), guideWidth, envelope.ux, envelope.uz, tostring(envelope.axisSource), colorId
        )
        local rootVehicle = self.rootVehicle or self
        if rootVehicle.requestActionEventUpdate ~= nil then
            rootVehicle:requestActionEventUpdate()
        end
    end
end

local function hasMowerSpecialization(self)
    if self == nil then
        return false
    end
    if self.spec_mower ~= nil then
        return true
    end
    if SpecializationUtil ~= nil and SpecializationUtil.hasSpecialization ~= nil
        and Mower ~= nil and self.specializations ~= nil then
        return SpecializationUtil.hasSpecialization(Mower, self.specializations)
    end
    return false
end

local function refreshMowerDynamicWidth(self, force)
    if self == nil or self.spec_mower == nil or self.jdWorkingWidthTargetWidth == nil then
        return
    end
    if self.components == nil or self.components[1] == nil then
        return
    end

    local now = g_time or 0
    if not force and self.jdWorkingWidthLastMowerRefresh ~= nil and now - self.jdWorkingWidthLastMowerRefresh < 150 then
        return
    end
    self.jdWorkingWidthLastMowerRefresh = now

    local root = self.components[1].node
    if not validNode(root) then
        return
    end

    -- First put the mower's authored cutting nodes back where the mower mod
    -- expects them.  Their parent deck components have already rotated into
    -- the current folded/unfolded pose at this point.
    restoreMowerOriginalNodes(self)

    local activeWorkAreas = getRelevantWorkAreas(self)
    if activeWorkAreas == nil or #activeWorkAreas == 0 then
        return
    end

    local success, _, finalWidth = resizeWorkAreas(
        self, root, self.jdWorkingWidthTargetWidth, activeWorkAreas, false, true
    )
    if success then
        self.jdWorkingWidthCurrentWidth = finalWidth or self.jdWorkingWidthTargetWidth
    end

    -- Guides are linked to the mower's main component, so recalculate their
    -- local placement too whenever the folding decks change pose.
    if self.isClient and (self.jdWorkingWidthGuideLeft ~= nil or self.jdWorkingWidthGuideRight ~= nil) then
        local plusX, plusZ, minusX, minusZ, _, guideYaw =
            getGuidePlacement(self, root, self.jdWorkingWidthTargetWidth, activeWorkAreas)
        if plusX ~= nil then
            if validNode(self.jdWorkingWidthGuideLeft) then
                applyGuideTransform(self.jdWorkingWidthGuideLeft, root, plusX, JDWorkingWidth.GUIDE_Y, plusZ, guideYaw)
            end
            if validNode(self.jdWorkingWidthGuideRight) then
                applyGuideTransform(self.jdWorkingWidthGuideRight, root, minusX, JDWorkingWidth.GUIDE_Y, minusZ, guideYaw)
            end
        end
    end
end

-- Mowers MUST be initialized after Mower.onPostLoad.  GIANTS fills
-- spec_mower.workAreas there from getTypedWorkAreas(WorkAreaType.MOWER).
-- Applying the generic WorkArea hook earlier can resize auxiliary/drop areas
-- before the Mower specialization has finished identifying the true cutters.
if Mower ~= nil and Mower.onPostLoad ~= nil and Utils ~= nil then
    Mower.onPostLoad = Utils.appendedFunction(
        Mower.onPostLoad,
        function(self, savegame)
            applyWorkingWidthAndGuides(self)
            refreshMowerDynamicWidth(self, true)
        end
    )
    Logging.info("[JDWorkingWidth] Mower post-load initialization hook installed")
end

-- Recalculate after folding animations have put the mower decks in their live
-- pose. This hook MUST run before the mower processing function so the density
-- map receives one stable corrected geometry for the entire cutting pass.
if Mower ~= nil and Mower.onStartWorkAreaProcessing ~= nil and Utils ~= nil then
    -- CRITICAL: apply the corrected mower geometry BEFORE GIANTS begins the
    -- work-area processing pass. Using appendedFunction here is too late and can
    -- make the density map cut with the previous frame's staggered deck geometry.
    Mower.onStartWorkAreaProcessing = Utils.prependedFunction(
        Mower.onStartWorkAreaProcessing,
        function(self, dt)
            refreshMowerDynamicWidth(self, true)
        end
    )
    Logging.info("[JDWorkingWidth] Mower geometry PRE-processing hook installed")
end

-- Do NOT rewrite mower WorkArea nodes from Mower.onUpdate. Repositioning them
-- asynchronously while GIANTS is processing density-map work areas can create
-- stepped/notched crop edges. Geometry is now refreshed only at post-load and
-- immediately before each mower processing pass.

if WorkArea ~= nil and WorkArea.onLoad ~= nil and Utils ~= nil then
    WorkArea.onLoad = Utils.appendedFunction(WorkArea.onLoad, function(self, savegame)
        -- Do not touch mower geometry at this early stage.  Mower.onPostLoad
        -- handles it after GIANTS has finalized spec_mower.workAreas.
        if not hasMowerSpecialization(self) then
            applyWorkingWidthAndGuides(self)
        end
    end)
    Logging.info("[JDWorkingWidth] Direct WorkArea.onLoad hook installed (mowers deferred to post-load)")
else
    Logging.error("[JDWorkingWidth] Could not install WorkArea.onLoad hook")
end

Logging.info("[JDWorkingWidth] v1.0.0.1 Runtime.lua loaded")
Logging.info("[JDWorkingWidth] guide files: '%s' | '%s'", tostring(JDWorkingWidth.LEFT_PATH), tostring(JDWorkingWidth.RIGHT_PATH))
