Przejdź do zawartości

Moduł:Build bracket/Paths

Z Wikipedii, wolnej encyklopedii
 Dokumentacja modułu[stwórz] • [odśwież]
local Paths = {}

-- ==============================
-- 1) MODULE STATE & HOT HELPERS
-- ==============================
local _pathsCache = {} -- memo for parsePaths during one build
local t_insert = table.insert -- stdlib alias
local mmin, mmax = math.min, math.max

-- upvalues bound per call (set in bind)
local state, config, Helpers, StateChecks
local split, notempty, bargs, getFArg, isBlankEntry
local defaultColor

local showByeOn = true -- toggle for bye through-lines
local base = 0 -- left-round base offset

-- cache for per-round “RDx-RDy-path” OFF flags
local rdGateOff = {}

-- thickness constants: renderer maps 1.0 => 2px, 0.5 => 1px
local STRONG = 1.0
local FEATHER = 0.5

-- Small helpers
local function tpmOf(col) -- teams per match for a given team column
    return (state.teamsPerMatch and state.teamsPerMatch[col]) or 2
end

local function entries(col) -- convenience accessor
    return state.entries and state.entries[col]
end

local function clearPathsCache() -- reset parse cache each build
    _pathsCache = {}
end

local function origJ(j) -- external round index from internal j
    return j + base
end

-- ===================
-- 2) ROW/COLUMN MATH
-- ===================
local function rowOf(j, m)
    local sj = (state.shift and state.shift[j]) or 0
    local tpm = tpmOf(j)
    -- map match index m to internal row for team slot center
    return 2 * (m + sj) + (tpm - 2)
end

-- ============================
-- 3) BIND + ARG/CONFIG WIRING
-- ============================
local function bind(_state, _config, _Helpers, _StateChecks)
    state, config, Helpers, StateChecks = _state, _config, _Helpers, _StateChecks
    split, notempty, bargs, getFArg = _Helpers.split, _Helpers.notempty, _Helpers.bargs, _Helpers.getFArg
    isBlankEntry = _StateChecks.isBlankEntry

    defaultColor = (config.COLORS and config.COLORS.path_line_color) or "gray"
    base = config.base or 0

    -- cache bye toggle once
    local v = (_Helpers.bargs and _Helpers.bargs("show-bye-paths")) or ""
    showByeOn = not Helpers.no(v)
end

-- Precompute RD gate flags once per build (RDx-RDy-path = "no" means gate ON => block)
local function computeRoundGates()
    rdGateOff = {}
    for j = config.minc, config.c - 1 do
        local rd = (bargs(("RD%d-RD%d-path"):format(origJ(j), origJ(j) + 1)) or ""):lower()
        rdGateOff[j] = Helpers.no(rd)
    end
end

-- ========================
-- 4) PATH PARSING + CACHE
-- ========================
local function parsePaths(j)
    local cached = _pathsCache[j]
    if cached then
        return cached
    end

    local oj = origJ(j)
    local str = (getFArg("col" .. oj .. "-col" .. (oj + 1) .. "-paths") or ""):gsub("%s+", "")
    local result = {}

    -- grammar: (a,b- c:d, e:f - color1,color2,...) etc.
    for val in str:gsub(",", ", "):gsub("%S+", "\0%0\0"):gsub(
        "%b()",
        function(s)
            return s:gsub("%z", "")
        end
    ):gmatch("%z(.-)%z") do
        -- strip parens once, split once
        local array = split(val:gsub("[()]", ""), {"-"})
        for k, _ in ipairs(array) do
            array[k] = split(array[k], {","})
        end

        if notempty(array[2]) then
            array[3] = array[3] or {}
            for m = 1, #array[2] do
                local pair = split(array[2][m], {":"})
                array[2][m] = pair[1]
                array[3][m] = pair[2]
            end
            for n = 1, #array[1] do
                local a = tonumber(array[1][n])
                for m = 1, #array[2] do
                    t_insert(result, {a, tonumber(array[2][m]), color = array[3][m]})
                end
            end
        end
    end

    _pathsCache[j] = result
    return result
end

-- ==============================
-- 5) STRUCTURAL READS & WINDOWS
-- ==============================
-- Through-line mask that already exists in a team column (from a previous step)
local function throughEdgesMaskAt(teamCol, sRow)
    local e = entries(teamCol)
    if not e then
        return false, false
    end
    local hasBottom =
        (sRow - 1 >= 1) and e[sRow - 1] and e[sRow - 1].ctype == "line" and
        (e[sRow - 1].border == "bottom" or e[sRow - 1].border == "both")
    local hasTop =
        (sRow + 1 <= config.r) and e[sRow + 1] and e[sRow + 1].ctype == "line" and
        (e[sRow + 1].border == "top" or e[sRow + 1].border == "both")
    return hasBottom, hasTop
end

-- window = the block of rows that constitutes a match near sRow
local function matchWindow(col, sRow)
    local tpm = tpmOf(col)
    local half = 2 * (tpm - 1) -- distance to top/bottom team rows
    local lo = mmax(1, sRow - half)
    local hi = mmin(config.r, sRow + half)
    return lo, hi, tpm
end

-- Single-pass scan: returns hasTeam, headerIndex (or nil), allTeamsBlank
local function scanWindow(col, sRow)
    local ent = entries(col)
    if not ent then
        return false, nil, true
    end
    local lo, hi = matchWindow(col, sRow)
    local hidx, hasTeam = nil, false
    local allBlank = true
    for r = lo, hi do
        local e = ent[r]
        if e then
            if not hidx and e.headerindex then
                hidx = e.headerindex
            end
            if e.ctype == "team" then
                hasTeam = true
                if allBlank and not isBlankEntry(col, r) then
                    allBlank = false
                end
            end
        end
    end
    return hasTeam, hidx, allBlank
end

-- =================================
-- 6) SLOT DISPOSITION / VISIBILITY
-- =================================
local slotDisp  -- j -> sRow -> 'visible' | 'through' | 'suppress'

local function computeSlotDispositions()
    slotDisp = {}
    local minc, c = config.minc, config.c
    local R = mmax(1, (config.r or 1) - 1)

    for j = minc, c do
        local sdj = {}
        slotDisp[j] = sdj
        for sRow = 1, R do
            local hasTeam, hidx, allBlank = scanWindow(j, sRow)

            if not hasTeam then
                sdj[sRow] = (j > minc and j < c) and "through" or "suppress"
            else
                local disp = "visible"
                if hidx then
                    local hid = state.hide and state.hide[j] and state.hide[j][hidx]
                    local byes = state.byes and state.byes[j] and state.byes[j][hidx]
                    if hid then
                        disp = "suppress"
                    elseif byes and allBlank then
                        disp = (showByeOn and j > minc and j < c) and "through" or "suppress"
                    end
                end
                sdj[sRow] = disp
            end
        end
    end
end

local function dispositionOf(j, sRow) -- on-demand fallback
    local sdj = slotDisp and slotDisp[j]
    if sdj and sdj[sRow] then
        return sdj[sRow]
    end

    local _, hidx, allBlank = scanWindow(j, sRow)
    if not hidx then
        return "visible"
    end

    local hid = state.hide and state.hide[j] and state.hide[j][hidx]
    local byes = state.byes and state.byes[j] and state.byes[j][hidx]
    if hid then
        return "suppress"
    end

    if byes and allBlank then
        if not showByeOn then
            return "suppress"
        end
        if j > config.minc and j < config.c then
            return "through"
        end
        return "suppress"
    end
    return "visible"
end

local function slotIsVisiblyReal(col, sRow)
    local hasTeam, hidx, allBlank = scanWindow(col, sRow)
    if not hasTeam then
        return false
    end
    if hidx then
        local hid = state.hide and state.hide[col] and state.hide[col][hidx]
        local byes = state.byes and state.byes[col] and state.byes[col][hidx]
        if hid then
            return false
        end
        if byes and (not showByeOn) and allBlank then
            return false
        end
    end
    return true
end

local function isThroughableTeamSlot(teamCol, sRow)
    return dispositionOf(teamCol, sRow) == "through"
end

-- =======================================
-- 7) RENDER PRIMITIVES (low-level utils)
-- =======================================
-- === Tiny cell primitives (keep paintVertical as-is) ===
local function ensurePathCell(j, i, k)
    state.pathCell[j] = state.pathCell[j] or {}
    state.pathCell[j][i] = state.pathCell[j][i] or {}
    local cell = state.pathCell[j][i][k]
    if not cell then
        cell = {{0, 0, 0, 0}, color = defaultColor}
        state.pathCell[j][i][k] = cell
    end
    return cell
end

-- Ensure all arrays for column j exist and are sized to R rows
local function ensureColumnScaffolding(j)
    local R = assert(config.r, "config.r must be set before scaffolding")
    state.pathCell[j] = state.pathCell[j] or {}
    state.crossCell[j] = state.crossCell[j] or {}
    state.skipPath[j] = state.skipPath[j] or {}

    for i = 1, R do
        -- make sure the 3 path lanes exist
        ensurePathCell(j, i, 1)
        ensurePathCell(j, i, 2)
        ensurePathCell(j, i, 3)

        -- default cross info
        state.crossCell[j][i] = state.crossCell[j][i] or {left = {0, defaultColor}, right = {0, defaultColor}}

        -- skipPath defaults to boolean
        if state.skipPath[j][i] == nil then
            state.skipPath[j][i] = false
        end
    end
end

local function paintEdge(j, i, k, edge, val, color)
    local cell = ensurePathCell(j, i, k)
    local b = cell[1]
    if val > (b[edge] or 0) then
        b[edge] = val
    end
    if color then
        cell.color = color
    end
end

-- === One generic "pair painter"  ===
-- kind: 'L' (left path col = k=1), 'C' (center = k=2), 'R' (right = k=3)
-- side: 'upper' | 'lower'
-- feather: true => opposite side FEATHER; false => opposite side omitted (no feather)
local K = {L = 1, C = 2, R = 3}
local TOP, BOT = 1, 3
local function paintPair(kind, side, feather, j, row, color)
    local k = K[kind]
    local R = config.r
    local strongAt, weakAt = (side == "upper") and BOT or TOP, (side == "upper") and TOP or BOT
    -- strong on owned side
    paintEdge(j, row + (side == "lower" and 1 or 0), k, strongAt, STRONG, color)
    -- optional feather on the other side (skip entirely when feather=false)
    if feather and row + (side == "upper" and 1 or 0) <= R then
        paintEdge(j, row + (side == "upper" and 1 or 0), k, weakAt, FEATHER, color)
    end
end

-- Tiny wrappers (purely to keep call sites readable/short)
local function OUT(j, row, side, feather, color)
    paintPair("L", side, feather, j, row, color)
end
local function JOIN(j, row, side, feather, color)
    paintPair("C", side, feather, j, row, color)
end
local function IN(j, row, side, feather, color)
    paintPair("R", side, feather, j, row, color)
end

-- vertical stroke (use left edge=2)
local function paintVertical(j, i1, i2, k, color)
    if not i1 or not i2 then
        return
    end
    local a, b = i1, i2
    if a > b then
        a, b = b, a
    end
    for r = a, b do
        paintEdge(j, r, k, 2, STRONG, color)
    end
end

-- ===============================
-- 8) CROSSING / COLORING HELPERS
-- ===============================
local function getCrossRowsFor(j)
    local oj = origJ(j)
    local raw = (getFArg("col" .. oj .. "-col" .. (oj + 1) .. "-cross") or ""):gsub("%s+", "")
    if raw == "" then
        return {}, false
    end

    local parts = split(raw, {","}, true)
    local rows = {}
    if notempty(parts[1]) then
        for idx = 1, #parts do
            local m = tonumber(parts[idx])
            if m then
                rows[#rows + 1] = rowOf(j, m)
            end
        end
    end
    return rows, (#rows > 0)
end

local function pickCrossBetween(crossRows, startRow, stopRow)
    if #crossRows == 0 then
        return 0
    end
    local lo, hi = startRow, stopRow
    if lo > hi then
        lo, hi = hi, lo
    end
    for _, cr in ipairs(crossRows) do
        if cr > lo and cr < hi then
            return cr
        end
    end
    return 0
end

local function paintVerticalWithCross(j, sRow, eRow, dir, color, crossRows, hasCross, centerUseByStart, inboundDownAt)
    local goingDown = (dir == "down")
    if goingDown then
        inboundDownAt[eRow] = true
    end

    local function markCU()
        local cu = centerUseByStart[sRow] or {up = false, down = false}
        if goingDown then
            cu.down = true
        else
            cu.up = true
        end
        centerUseByStart[sRow] = cu
    end

    if not hasCross then
        if goingDown then
            paintVertical(j, sRow + 1, eRow, 1, color)
            IN(j, eRow, "upper", false, color)
        else
            paintVertical(j, eRow + 1, sRow, 1, color)
            IN(j, eRow, "lower", false, color)
        end
        markCU()
        return
    end

    local cr = pickCrossBetween(crossRows, sRow, eRow)
    if cr ~= 0 then
        if goingDown then
            paintVertical(j, sRow + 1, cr - 1, 1, color)
            state.crossCell[j][cr].left = {1, color}
            state.crossCell[j][cr].right = state.crossCell[j][cr].right or {0, color}
            paintVertical(j, cr + 2, eRow, 2, color)
            IN(j, eRow, "upper", false, color)
        else
            paintVertical(j, eRow + 1, cr - 1, 2, color)
            state.crossCell[j][cr].right = {1, color}
            state.crossCell[j][cr].left = state.crossCell[j][cr].left or {0, color}
            paintVertical(j, cr + 2, sRow, 1, color)
            IN(j, eRow, "lower", false, color)
        end
    else
        if goingDown then
            paintVertical(j, sRow + 1, eRow, 2, color)
            IN(j, eRow, "upper", false, color)
        else
            paintVertical(j, eRow + 1, sRow, 2, color)
            IN(j, eRow, "lower", false, color)
        end
        markCU()
    end
end

-- Detect incoming style (from left path column) per row:
-- returns: map[row] = { side = "upper"|"lower", feather = true|false }
local function detectIncomingStyle(j)
    local res = {}
    local left = state.pathCell and state.pathCell[j - 1]
    if not left then
        return res
    end

    local R = config.r
    for i = 1, R - 1 do
        local cU = left[i] and left[i][3] and left[i][3][1] or {0, 0, 0, 0} -- row i, k=3
        local cL = left[i + 1] and left[i + 1][3] and left[i + 1][3][1] or {0, 0, 0, 0}
        -- row i+1, k=3

        local upperStrong = (cU[3] or 0) == STRONG
        local lowerStrong = (cL[1] or 0) == STRONG
        local upperFeather = (cL[1] or 0) > 0 and (cL[1] ~= STRONG) -- L feather present?
        local lowerFeather = (cU[3] or 0) > 0 and (cU[3] ~= STRONG) -- U feather present?

        if upperStrong and not lowerStrong then
            res[i] = {side = "upper", feather = upperFeather}
        elseif lowerStrong and not upperStrong then
            res[i] = {side = "lower", feather = lowerFeather}
        end
    end
    return res
end

-- If no edge is painted, returns 0, nil
local function getEdge(col, row, k, edge)
    local pc = state.pathCell and state.pathCell[col]
    local cell = pc and pc[row] and pc[row][k]
    if not cell then
        return 0, nil
    end
    local w = (cell[1] and cell[1][edge]) or 0
    return w, (w ~= 0) and cell.color or nil
end

local function finalizeThroughLineColors()
    for teamCol = config.minc + 1, config.c do
        local leftPathCol = teamCol - 1
        local rightPathCol = teamCol
        local Ent = state.entries[teamCol]
        if Ent then
            for sRow = 1, config.r - 1 do
                local hasBottom, hasTop = throughEdgesMaskAt(teamCol, sRow)

                if hasBottom and Ent[sRow - 1] and Ent[sRow - 1].ctype == "line" then
                    -- bottom edges at row sRow (left k=3, right k=1) use edge=3
                    local wL, cL = getEdge(leftPathCol, sRow, 3, 3)
                    local wR, cR = getEdge(rightPathCol, sRow, 1, 3)
                    Ent[sRow - 1].color = (wL ~= 0 and wR ~= 0 and cL == cR) and cL or nil
                end

                if hasTop and Ent[sRow + 1] and Ent[sRow + 1].ctype == "line" then
                    -- top edges at row sRow+1 use edge=1
                    local wL, cL = getEdge(leftPathCol, sRow + 1, 3, 1)
                    local wR, cR = getEdge(rightPathCol, sRow + 1, 1, 1)
                    Ent[sRow + 1].color = (wL ~= 0 and wR ~= 0 and cL == cR) and cL or nil
                end
            end
        end
    end
end

-- =========================
-- 9) PATH VISIBILITY GATES
-- =========================
local function hasVisibleOutgoingFrom(j, startRow)
    if j >= config.c then
        return false
    end
    for _, q in ipairs(parsePaths(j) or {}) do
        local qStartRow = rowOf(j, q[1])
        if qStartRow == startRow then
            local qStopRow = rowOf(j + 1, q[2])
            if dispositionOf(j + 1, qStopRow) ~= "suppress" then
                return true
            end
        end
    end
    return false
end

local function roundGateBlocks(col, startRow)
    if not rdGateOff[col] then
        return false
    end
    local _, hidx = scanWindow(col, startRow)
    return (hidx == 1)
end

local function hasNonSuppressedIncomingTo(j, startRow)
    if j <= config.minc then
        return false
    end
    for _, q in ipairs(parsePaths(j - 1) or {}) do
        if rowOf(j, q[2]) == startRow then
            local leftStart = rowOf(j - 1, q[1])
            if dispositionOf(j - 1, leftStart) ~= "suppress" and not roundGateBlocks(j - 1, leftStart) then
                return true
            end
        end
    end
    return false
end

local function isPathHidden(j, start, stop, leftDisp, rightDisp)
    leftDisp = leftDisp or dispositionOf(j, start)
    rightDisp = rightDisp or dispositionOf(j + 1, stop)

    if leftDisp == "suppress" or rightDisp == "suppress" then
        return true
    end
    do
        local b, t = throughEdgesMaskAt(j, start)
        if not (slotIsVisiblyReal(j, start) or b or t) then
            return true
        end
    end
    do
        local b, t = throughEdgesMaskAt(j + 1, stop)
        if not (slotIsVisiblyReal(j + 1, stop) or isThroughableTeamSlot(j + 1, stop) or b or t) then
            return true
        end
    end
    if leftDisp == "through" and not hasNonSuppressedIncomingTo(j, start) then
        return true
    end
    if rightDisp == "through" and not hasVisibleOutgoingFrom(j + 1, stop) then
        return true
    end
    if rdGateOff[j] then
        local _, hidx = scanWindow(j, start)
        if hidx == 1 then
            return true
        end
    end
    return false
end

-- ============================
-- 10) THROUGH-LINES INSERTION
-- ============================
local function insertThroughLinesForColumn(j)
    if not showByeOn then
        return
    end
    local Pj = state.pathCell[j]
    if not Pj then
        return
    end

    state.entries[j + 1] = state.entries[j + 1] or {}
    local Ent, R = state.entries[j + 1], config.r

    for i = 1, R - 1 do
        if isThroughableTeamSlot(j + 1, i) then
            local c_i3 = Pj[i] and Pj[i][3]
            local c_ip1_3 = Pj[i + 1] and Pj[i + 1][3]
            local b_i3 = c_i3 and c_i3[1] or nil
            local b_ip1_3 = c_ip1_3 and c_ip1_3[1] or nil

            local hasBottom = (b_i3 and (b_i3[3] or 0) ~= 0) or false
            local hasTop = (b_ip1_3 and (b_ip1_3[1] or 0) ~= 0) or false

            -- bounds guards
            if hasBottom and (i - 1) >= 1 then
                Ent[i - 1] = {ctype = "line", border = "bottom"}
                Ent[i] = Ent[i] or {ctype = "blank"}
            end
            if hasTop and (i + 1) <= R then
                Ent[i + 1] = {ctype = "line", border = "top"}
                if (i + 2) <= R then
                    Ent[i + 2] = Ent[i + 2] or {ctype = "blank"}
                end
            end
        end
    end
end

-- Thin same-color double-in at the right path column (k=3):
-- If both U (row i, edge=3) and L (row i+1, edge=1) are STRONG and colors match,
-- make L a FEATHER => 2U/1L.
local function thinDoubleInSameColorForColumn(j)
    local R = config.r
    local pc = state.pathCell[j]
    if not pc then
        return
    end

    for i = 1, R - 1 do
        local cellU = pc[i] and pc[i][3] -- row i, k=3 (U edge lives here at b[3])
        local cellL = pc[i + 1] and pc[i + 1][3] -- row i+1, k=3 (L edge lives here at b[1])
        if cellU and cellL and cellU[1] and cellL[1] then
            local bU, bL = cellU[1], cellL[1]
            local uStrong = (bU[3] or 0) == STRONG
            local lStrong = (bL[1] or 0) == STRONG
            -- Only thin when BOTH sides are strong and colors match
            if uStrong and lStrong and cellU.color == cellL.color then
                bL[1] = FEATHER -- 2U2L -> 2U1L
            -- keep color as-is (already the same on both cells)
            end
        end
    end
end

local function rememberRowStyle(rowStyleByColor, sRow, color, side, feather)
    local map = rowStyleByColor[sRow]
    if not map then
        map = {}
        rowStyleByColor[sRow] = map
    end
    map[color] = {side = side, feather = feather}
end

local function summarizeDirs(items)
    local hasUp, hasDown, hasStraight = false, false, false
    local upColor, downColor, straightColor
    for _, it in ipairs(items) do
        if it.dir == "up" then
            hasUp, upColor = true, it.color
        end
        if it.dir == "down" then
            hasDown, downColor = true, it.color
        end
        if it.dir == "straight" then
            hasStraight, straightColor = true, it.color
        end
    end
    return hasUp, hasDown, hasStraight, upColor, downColor, straightColor
end

-- ===========================
-- 11) MAIN PATH DRAWING LOOP
-- ===========================
local function drawPathsForColumn(j)
    local R = config.r

    ensureColumnScaffolding(j)

    local crossRows, hasCross = getCrossRowsFor(j)
    state.hascross[j] = hasCross

    local paths = parsePaths(j) or {}
    local outByStart, straightStubs, centerUseByStart, inboundDownAt = {}, {}, {}, {}

    -- record the chosen OUT style per starting row and color for this column
    local rowStyleByColor = {} -- [sRow] -> { [color] = { side="upper"|"lower", feather=bool } }

    -- paint verticals + collect fork info
    for _, p in ipairs(paths) do
        local sIdx, eIdx = p[1], p[2]
        local color = p.color or defaultColor
        local sRow = rowOf(j, sIdx)
        local eRow = rowOf(j + 1, eIdx)

        -- skip if either endpoint suppressed; avoid cycles by using isPathHidden
        local leftDisp = dispositionOf(j, sRow)
        local rightDisp = dispositionOf(j + 1, eRow)
        if not isPathHidden(j, sRow, eRow, leftDisp, rightDisp) then
            local dir = (sRow < eRow) and "down" or ((sRow > eRow) and "up" or "straight")
            if dir == "down" then
                inboundDownAt[eRow] = true
            end

            local bucket = outByStart[sRow]
            if not bucket then
                bucket = {}
                outByStart[sRow] = bucket
            end
            bucket[#bucket + 1] = {dir = dir, color = color}

            if dir ~= "straight" then
                paintVerticalWithCross(j, sRow, eRow, dir, color, crossRows, hasCross, centerUseByStart, inboundDownAt)
            else
                local list = straightStubs[sRow] or {}
                straightStubs[sRow] = list
                list[#list + 1] = {stopRow = eRow, color = color}
            end
        end
    end

    -- decide fork placements and paint edges/joins/stubs
    local incomingStyle = detectIncomingStyle(j) -- row -> {side, feather}

    for sRow, items in pairs(outByStart) do
        local hasUp, hasDown, hasStraight, upColor, downColor, straightColor = summarizeDirs(items)
        local straightPlacement = "upper" -- default
        local wantTopLower, topColor = false, nil
        local wantBottomUpper, bottomColor = false, nil

        if hasUp then
            wantBottomUpper = true
            bottomColor = upColor
        end
        if hasDown then
            wantTopLower = true
            topColor = downColor
        end

        if hasStraight then
            if hasUp then
                straightPlacement = "lower"
            else
                local needsLower = false
                local list = straightStubs[sRow]
                if list then
                    for _, stub in ipairs(list) do
                        if inboundDownAt[stub.stopRow] then
                            needsLower = true
                            break
                        end
                    end
                end
                if needsLower then
                    straightPlacement = "lower"
                end
            end

            if straightPlacement == "lower" then
                wantTopLower = true
                topColor = straightColor or topColor
            else
                wantBottomUpper = true
                bottomColor = straightColor or bottomColor
            end
        end

        -- obey forced incoming placement if a through-edge exists from the left
        local appliedForced = nil
        do
            local leftHasBottom, leftHasTop = throughEdgesMaskAt(j, sRow)
            if (leftHasBottom or leftHasTop) then
                local info = incomingStyle[sRow] -- { side="upper"|"lower", feather=... } or nil
                if info and (info.side == "upper" or info.side == "lower") then
                    appliedForced = info.side
                    -- make side intent explicit so later logic is consistent
                    wantBottomUpper, wantTopLower = (appliedForced == "upper"), (appliedForced == "lower")
                    if appliedForced == "upper" then
                        bottomColor = bottomColor or straightColor or upColor or downColor or defaultColor
                        straightPlacement = "upper"
                    else
                        topColor = topColor or straightColor or upColor or downColor or defaultColor
                        straightPlacement = "lower"
                    end
                end
            end
        end

        -- preserve prior through-edges if present
        do
            local prevBottom, prevTop = throughEdgesMaskAt(j, sRow)
            if prevBottom or prevTop then
                wantBottomUpper = prevBottom
                wantTopLower = prevTop
                if prevBottom ~= prevTop then
                    appliedForced = prevBottom and "upper" or "lower"
                    straightPlacement = appliedForced
                end
            end
        end

        -- Decide the style to use for a single outgoing path on this row.
        local isSingleOut = (#items == 1)

        -- default for single-out: always "upper" (2U/1L), unless forced by left through
        local styleSingle
        if isSingleOut then
            styleSingle = appliedForced or "upper"
        else
            -- multi-out: keep existing side selection logic
            if appliedForced then
                styleSingle = appliedForced -- "upper" | "lower"
            elseif wantBottomUpper and not wantTopLower then
                styleSingle = "upper"
            elseif wantTopLower and not wantBottomUpper then
                styleSingle = "lower"
            else
                styleSingle = "upper"
            end
        end

        -- If we have a through edge from the left, try to copy feather/no-feather from the incoming.
        local lb, lt = throughEdgesMaskAt(j, sRow)
        local throughHere = (dispositionOf(j, sRow) == "through") or lb or lt
        local forcedInfo = throughHere and incomingStyle[sRow] or nil -- gate by through
        local forcedFeather = forcedInfo and forcedInfo.feather -- true | false | nil

        if isSingleOut then
            local singleColor = (items[1] and items[1].color) or bottomColor or topColor or defaultColor

            if forcedFeather == false then
                -- through row and incoming was NO feather → out₂ NO feather
                OUT(j, sRow, styleSingle, false, singleColor)
                rememberRowStyle(rowStyleByColor, sRow, singleColor, styleSingle, false)
            else
                -- either not a through row, or incoming was feather/unknown → use feathered out (2U/1L)
                OUT(j, sRow, styleSingle, true, singleColor)
                rememberRowStyle(rowStyleByColor, sRow, singleColor, styleSingle, true)
            end
        else
            -- Multi-out: split sides (no feather), and record
            local upC = hasUp and (upColor or defaultColor) or nil
            local downC = hasDown and (downColor or defaultColor) or nil
            local straightC = hasStraight and (straightColor or defaultColor) or nil

            local upperColors, lowerColors = {}, {}

            if hasUp and hasDown and not hasStraight then
                upperColors[upC] = true -- up → U
                lowerColors[downC] = true -- down → L
            elseif hasStraight and hasDown and not hasUp then
                upperColors[straightC] = true -- straight → U
                lowerColors[downC] = true -- down → L
            elseif hasStraight and hasUp and not hasDown then
                upperColors[upC] = true -- up → U
                lowerColors[straightC] = true -- straight → L
            else
                if wantBottomUpper then
                    upperColors[(bottomColor or defaultColor)] = true
                end
                if wantTopLower then
                    lowerColors[(topColor or defaultColor)] = true
                end
            end

            if next(upperColors) then
                for colstr, _ in pairs(upperColors) do
                    paintEdge(j, sRow, 1, 3, STRONG, colstr) -- 2U, 0L
                    rememberRowStyle(rowStyleByColor, sRow, colstr, "upper", false)
                end
            end
            if next(lowerColors) and (sRow + 1) <= R then
                for colstr, _ in pairs(lowerColors) do
                    paintEdge(j, sRow + 1, 1, 1, STRONG, colstr) -- 0U, 2L
                    rememberRowStyle(rowStyleByColor, sRow, colstr, "lower", false)
                end
            end
        end

        local cu = centerUseByStart[sRow]
        if state.hascross[j] then
            -- Look up side+feather recorded for this row+color (falls back to a side, defaults feather=true)
            local function styleObjFor(color, fallbackSide)
                local map = rowStyleByColor[sRow]
                local info = map and color and map[color]
                if info then
                    return info.side, info.feather
                end
                return fallbackSide, true
            end

            -- Non-crossing UP hop uses center join: mimic out style for that color
            if cu and cu.up then
                local color = upColor or defaultColor
                local side, feather = styleObjFor(color, appliedForced or "upper")
                if feather == false then
                    JOIN(j, sRow, side, false, color)
                else
                    JOIN(j, sRow, side, true, color)
                end
            end

            -- Non-crossing DOWN hop uses center join: mimic out style for that color
            if cu and cu.down then
                local color = downColor or defaultColor
                local side, feather = styleObjFor(color, appliedForced or "lower")
                if feather == false then
                    JOIN(j, sRow, side, false, color)
                else
                    JOIN(j, sRow, side, true, color)
                end
            end

            -- Non-crossing STRAIGHT(s): there can be multiple colors; do one join per color
            if hasStraight and straightStubs[sRow] then
                local seen = {}
                for _, stub in ipairs(straightStubs[sRow]) do
                    local color = stub.color or bottomColor or topColor or defaultColor
                    if not seen[color] then
                        seen[color] = true
                        local fallbackSide = (straightPlacement == "lower" and "lower" or "upper")
                        local side, feather = styleObjFor(color, fallbackSide)
                        if feather == false then
                            JOIN(j, sRow, side, false, color)
                        else
                            JOIN(j, sRow, side, true, color)
                        end
                    end
                end
            end
        end

        if hasStraight and straightStubs[sRow] then
            for _, stub in ipairs(straightStubs[sRow]) do
                local color = stub.color or bottomColor or topColor or defaultColor
                local info = rowStyleByColor[sRow] and rowStyleByColor[sRow][color]
                local side =
                    (info and info.side) or appliedForced or styleSingle or
                    (straightPlacement == "lower" and "lower" or "upper")

                if info and info.feather == false then
                    -- origin was a shared split → IN should be no-feather (2U/0L or 0U/2L)
                    IN(j, stub.stopRow, side, false, color)
                else
                    -- single-out or unknown → IN mimics feathered OUT (2U/1L or 1U/2L)
                    IN(j, stub.stopRow, side, true, color)
                end
            end
        end
    end
    -- Post-pass: thin same-color double-in stubs on this column
    thinDoubleInSameColorForColumn(j)
end

-- ===============
-- 12) PUBLIC API
-- ===============
function Paths.build(_state, _config, _Helpers, _StateChecks)
    bind(_state, _config, _Helpers, _StateChecks)
    clearPathsCache()
    computeRoundGates()
    computeSlotDispositions()

    -- draw each path column, then immediately create through-lines for the team
    -- column to its right so the next path column can "see" them
    for j = config.minc, config.c - 1 do
        drawPathsForColumn(j)
        insertThroughLinesForColumn(j)
    end

    finalizeThroughLineColors()

    -- ensure last column exists for renderer (fills arrays to full size)
    ensureColumnScaffolding(config.c)
end

-- =======================
-- 13) POST-PASS GROUPING
-- =======================
local function isBlankOrBlankText(col, row)
    local ent = entries(col)
    if not ent then
        return true
    end
    local top = ent[row]
    local bot = ent[row + 1]
    if top == nil then
        if bot == nil then
            return true
        end
        if bot.ctype == "text" and isBlankEntry(col, row + 1) then
            return true
        end
    elseif top.ctype == "text" and isBlankEntry(col, row) then
        return true
    end
    return false
end

local function applyGroups() -- cosmetic grouping labels spanning left
    local R = config.r
    for j = config.minc, config.c - 1 do
        if (state.teamsPerMatch[j] or 2) == 2 then
            local groupIndex = 0
            for i = 1, R - 1 do
                local hasBottom = (getEdge(j, i, 3, 3) == STRONG)
                local hasTop = (getEdge(j, i + 1, 3, 1) == STRONG)
                if hasBottom or hasTop then
                    groupIndex = groupIndex + 1
                    if isBlankOrBlankText(j, i) then
                        local k = 0
                        repeat
                            local col = j - k
                            if
                                state.entries[col] and state.entries[col][i + 1] and
                                    state.entries[col][i + 1].ctype == "text" and
                                    isBlankEntry(col, i + 1)
                             then
                                state.entries[col][i + 2] = nil
                            end

                            state.entries[col] = state.entries[col] or {}
                            state.entries[col][i] = {ctype = "blank"}
                            state.entries[col][i + 1] = {ctype = "blank"}

                            if k > 0 and StateChecks.noPaths(col, i) then
                                state.skipPath[col] = state.skipPath[col] or {}
                                state.skipPath[col][i] = true
                                state.skipPath[col][i + 1] = true
                            end

                            k = k + 1
                        until k > (j - 1) or not isBlankOrBlankText(j - k, i) or not StateChecks.noPaths(j - k, i)

                        k = k - 1
                        local leftCol = j - k
                        local oj = origJ(j)
                        state.entries[leftCol] = state.entries[leftCol] or {}
                        state.entries[leftCol][i] = {
                            ctype = "group",
                            index = groupIndex,
                            colspan = k + 1,
                            group = bargs("RD" .. oj .. "-group" .. groupIndex),
                            align = "center"
                        }
                        state.entries[leftCol][i + 1] = {ctype = "blank"}
                    end
                end
            end
        end
    end
end

function Paths.attachGroups(_state, _config, _Helpers, _StateChecks)
    if _state then
        bind(_state, _config, _Helpers, _StateChecks)
    end
    applyGroups()
end

return Paths