#include "WorldRenderer.h"

#include <algorithm>
#include <cmath>
#include <functional>
#include <set>

#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QFont>
#include <QLinearGradient>
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#include <QRegion>
#include <QRegularExpression>
#include <QSvgRenderer>

#include "AttackBehavior.h"
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "DebrisComponent.h"
#include "DebrisSystem.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
#include "FactoryQueries.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ItemIconCache.h"
#include "PlacementRules.h"
#include "PortGeometry.h"
#include "PositionComponent.h"
#include "ProductionRules.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
#include "SelectionBounds.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "SurfaceMask.h"
#include "TunnelCompletion.h"
#include "WorldPrimitives.h"

namespace
{

// --- World building icons (REQ-UI-WORLD-ICON) --------------------------------

// Building types that render with a world icon, and the icon file name for each.
// Belts, splitters, and tunnels are intentionally absent so their orientation
// stays readable; the two defence stations share one symbol (colored per side by
// the fill it is drawn over).
struct WorldIconEntry { BuildingType type; const char* file; };
const WorldIconEntry kWorldIconFiles[] = {
    { BuildingType::Miner,                "miner.svg"              },
    { BuildingType::Smelter,              "smelter.svg"            },
    { BuildingType::Assembler,            "assembler.svg"          },
    { BuildingType::ReprocessingPlant,    "reprocessing_plant.svg" },
    { BuildingType::Shipyard,             "shipyard.svg"           },
    { BuildingType::SalvageBay,           "salvage_bay.svg"        },
    { BuildingType::Hq,                   "hq.svg"                 },
    { BuildingType::PlayerDefenceStation, "station.svg"            },
    { BuildingType::EnemyDefenceStation,  "station.svg"            },
};

// On-screen size of every world icon, as a multiple of one tile (REQ-UI-WORLD-ICON):
// a little over one tile so all buildings' icons read at the same size regardless
// of footprint.
const qreal kWorldIconTileFactor = 1.25;

// Produces an icon SVG containing only the glyph (the chip background rect
// stripped), with the white glyph stroke recolored to inkHex.
QByteArray worldIconSvg(const QByteArray& svg, const QString& inkHex)
{
    QString s = QString::fromUtf8(svg);
    // Drop the full-canvas chip rect only; some glyphs use their own <rect>
    // elements (e.g. the miner's drill housing, the hq's base), so match the
    // 100x100 background rect specifically.
    static const QRegularExpression backgroundRect(
        QStringLiteral("<rect\\s+width=\"100\"\\s+height=\"100\"[^>]*>"));
    s.remove(backgroundRect);
    s.replace(QStringLiteral("#ffffff"), inkHex);
    return s.toUtf8();
}

// Perceived luminance test; picks a dark glyph on light fills so it stays legible.
bool isLightFill(const QColor& c)
{
    const double lum = (0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()) / 255.0;
    return lum > 0.6;
}

// Fill color for a building's status light per its production state
// (REQ-UI-STATUS-LIGHT).
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
{
    switch (status)
    {
    case ProductionStatus::Unconfigured: return sl.grey;
    case ProductionStatus::Producing:    return sl.green;
    case ProductionStatus::Starved:      return sl.red;
    case ProductionStatus::Blocked:      return sl.yellow;
    }
    return sl.grey;
}

}  // namespace

WorldRenderer::WorldRenderer(const Simulation& sim, const VisualsConfig& visuals,
                             ItemIconCache* itemIcons, const std::string& configDir)
    : m_sim(sim)
    , m_visuals(visuals)
    , m_itemIcons(itemIcons)
{
    loadBuildingIcons(configDir);
}

WorldRenderer::~WorldRenderer() = default;

void WorldRenderer::render(QPainter& painter, const WorldCoordinates& coordinates,
                           const WorldRenderFrame& frame)
{
    drawTiles(painter, coordinates, frame);
    drawBuildings(painter, coordinates, frame);
    // Port items are drawn over the buildings but clipped to a thin margin at each
    // machine's edges (see drawPortItems), so items appear to emerge from / sink
    // into the port and stay visible while crossing directly between two touching
    // buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE).
    drawPortItems(painter, coordinates, frame);
    drawStations(painter, coordinates, frame);
    drawBeltItems(painter, coordinates, frame);
    drawDebris(painter, coordinates, frame);
    if (frame.isDebugDrawEnabled)
    {
        drawDebugSensorRanges(painter, coordinates, frame);
        drawDebugTargetLines(painter, coordinates, frame);
    }
    drawShips(painter, coordinates, frame);
    drawBeams(painter, coordinates, frame);
    drawOverlays(painter, coordinates, frame);
}

std::optional<QVector2D> WorldRenderer::entityPosition(entt::entity entity) const
{
    if (!m_sim.getAdmin().isValid(entity) || !m_sim.getAdmin().hasAll<PositionComponent>(entity))
    {
        return std::nullopt;
    }
    return m_sim.getAdmin().get<PositionComponent>(entity).value;
}

void WorldRenderer::drawPortGlyph(QPainter& painter,
                                  const WorldCoordinates& coordinates, QPoint tile,
                                  Rotation direction, const QColor& color,
                                  bool centered)
{
    const float px = coordinates.getTilePx();
    const QRectF tr = coordinates.tileRect(tile);
    const QPointF center(tr.x() + static_cast<qreal>(px) * 0.5,
                         tr.y() + static_cast<qreal>(px) * 0.5);

    QPointF edgeOffset;
    const char* ch;
    switch (direction)
    {
    case Rotation::East:  edgeOffset = QPointF(px * 0.25f,  0);   ch = ">"; break;
    case Rotation::West:  edgeOffset = QPointF(-px * 0.25f, 0);   ch = "<"; break;
    case Rotation::North: edgeOffset = QPointF(0,  -px * 0.25f);  ch = "^"; break;
    case Rotation::South: edgeOffset = QPointF(0,   px * 0.25f);  ch = "v"; break;
    default:              return;
    }

    // Centered glyphs sit in the middle of the tile (used for a port's target
    // cell, REQ-UI-PORT-TARGET-GLYPH) and are drawn 150% larger to stand out;
    // otherwise offset toward the exit edge of the port's body tile
    // (REQ-UI-PORT-GLYPH).
    const QPointF offset = centered ? QPointF(0.0, 0.0) : edgeOffset;
    const qreal glyphScale = centered ? 1.5 : 1.0;

    const qreal half = static_cast<qreal>(px) * 0.3 * glyphScale;
    const QPointF pos = center + offset;
    const QRectF textRect(pos.x() - half, pos.y() - half, half * 2.0, half * 2.0);

    QFont f = painter.font();
    f.setPixelSize(std::max(6, static_cast<int>(px * 0.4f * glyphScale)));
    painter.setFont(f);
    painter.setPen(color);
    painter.drawText(textRect, Qt::AlignCenter, QString::fromLatin1(ch));
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

void WorldRenderer::drawTiles(QPainter& painter, const WorldCoordinates& coordinates,
                              const WorldRenderFrame& /*frame*/)
{
    const int leftTile   = static_cast<int>(std::floor(coordinates.getViewLeftTiles())) - 1;
    const int rightTile  = leftTile
        + static_cast<int>(std::ceil(coordinates.getViewportWidthTiles())) + 2;
    const int bottomTile = m_sim.getConfig().world.heightTiles;

    // Asteroid columns left of the buildable edge are not yet unlocked by expansion;
    // tint them so the player sees the reachable-but-locked area. The columns one
    // purchase away take a lighter tint than the ground behind them, so the boundary
    // between the two says how far the next expansion reaches, and the expansion button
    // stands in the middle of them (REQ-UI-LOCKED-ASTEROID, REQ-UI-EXPAND-BUTTON).
    const int buildableLeftX = -m_sim.getCurrentAsteroidWidth_tiles();
    const int nextExpansionLeftX =
        buildableLeftX - m_sim.getConfig().world.expansion.columnsPerExpansion_tiles;

    painter.setPen(Qt::NoPen);
    for (int x = leftTile; x <= rightTile; ++x)
    {
        const QColor& fill = (x < 0)
            ? m_visuals.asteroid.fill
            : m_visuals.space.fill;
        const bool locked = (x < buildableLeftX);
        const QColor& tint = (x >= nextExpansionLeftX)
            ? m_visuals.overlays.nextExpansion
            : m_visuals.overlays.lockedAsteroid;
        for (int y = 0; y < bottomTile; ++y)
        {
            const QRectF rect = coordinates.tileRect(QPoint(x, y));
            painter.fillRect(rect, fill);
            if (locked)
            {
                painter.fillRect(rect, tint);
            }
        }
    }
}

void WorldRenderer::loadBuildingIcons(const std::string& configDir)
{
    // Icons live beside the config dir, read at runtime like visuals.toml
    // (REQ-UI-WORLD-ICON), mirroring how MainWindow derives the same path.
    const QString iconDir = QDir::cleanPath(
        QString::fromStdString(configDir) + "/../icons/buildings");

    for (const WorldIconEntry& entry : kWorldIconFiles)
    {
        QFile file(iconDir + "/" + QString::fromLatin1(entry.file));
        if (!file.open(QIODevice::ReadOnly))
        {
            continue;  // A missing icon is not an error; the text glyph is used.
        }
        const QByteArray svg = file.readAll();
        BuildingIconRenderers renderers;
        renderers.white = std::make_unique<QSvgRenderer>(
            worldIconSvg(svg, QStringLiteral("#ffffff")));
        renderers.dark = std::make_unique<QSvgRenderer>(
            worldIconSvg(svg, QStringLiteral("#1c1c20")));
        m_buildingIcons[entry.type] = std::move(renderers);
    }
}

bool WorldRenderer::drawBuildingIcon(QPainter& painter,
                                     const WorldCoordinates& coordinates,
                                     BuildingType type,
                                     const QRectF& box, const QColor& fill) const
{
    const std::map<BuildingType, BuildingIconRenderers>::const_iterator it =
        m_buildingIcons.find(type);
    if (it == m_buildingIcons.end()) { return false; }

    // Every icon is the same fixed on-screen size, centered on the footprint,
    // regardless of footprint size (REQ-UI-WORLD-ICON).
    const qreal side = static_cast<qreal>(coordinates.getTilePx()) * kWorldIconTileFactor;
    const QRectF target(box.center().x() - side / 2.0,
                        box.center().y() - side / 2.0, side, side);
    QSvgRenderer* renderer = isLightFill(fill) ? it->second.dark.get()
                                               : it->second.white.get();

    // Render the glyph as vector at the view scale so it stays crisp (a
    // pre-rasterized pixmap downscaled to tile size looked blurry).
    const bool wasAntialiasing = painter.testRenderHint(QPainter::Antialiasing);
    painter.setRenderHint(QPainter::Antialiasing, true);
    renderer->render(&painter, target);
    painter.setRenderHint(QPainter::Antialiasing, wasAntialiasing);
    return true;
}

void WorldRenderer::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates,
                                  const WorldRenderFrame& frame)
{
    for (const Building& b : getAllBuildings(m_sim.getFactoryState()))
    {
        const std::map<BuildingType, BuildingVisuals>::const_iterator it =
            m_visuals.buildings.find(b.type);
        if (it == m_visuals.buildings.end()) { continue; }
        const BuildingVisuals& bv = it->second;

        painter.setPen(Qt::NoPen);
        for (const QPoint& cell : b.bodyCells)
        {
            painter.fillRect(coordinates.tileRect(cell), bv.fill);
        }

        const QPointF tl = coordinates.tileToWidget(b.anchor);
        const QRectF bboxRect(tl.x(), tl.y(),
            b.footprint.width()  * static_cast<qreal>(coordinates.getTilePx()),
            b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));

        painter.setPen(QPen(bv.outline, 1));
        painter.setBrush(Qt::NoBrush);
        painter.drawRect(bboxRect);

        // Icon glyph over the fill (REQ-UI-WORLD-ICON); falls back to the text
        // glyph for building types without a world icon (e.g. tunnels).
        if (!drawBuildingIcon(painter, coordinates, b.type, bboxRect, bv.fill)
            && !bv.glyph.isEmpty())
        {
            painter.setPen(bv.outline);
            painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
        }

        for (const Port& port : b.outputPorts)
        {
            drawPortGlyph(painter, coordinates,
                          outputBodyTile(port.tile, port.direction),
                          port.direction, bv.outline, /*centered*/ false);
        }

        // Status light: a small circle in the building's upper-right corner
        // (in default/East orientation), anchored to that footprint corner and
        // rotating with the building (REQ-UI-STATUS-LIGHT). All status-light
        // building footprints are rectangular, so a corner of the axis-aligned
        // bounding box is the true corner.
        if (const std::optional<ProductionStatus> status =
                getProductionStatus(m_sim.getConfig(), b))
        {
            const float px    = coordinates.getTilePx();
            const float r     = px * 0.18f;
            const float inset = r + px * 0.12f;
            // Default orientation (East) puts the light at the top-right corner;
            // clockwise rotation carries it around the footprint.
            QPointF center(bboxRect.right() - inset, bboxRect.top() + inset);
            switch (b.rotation)
            {
            case Rotation::East:  break;
            case Rotation::South: center = QPointF(bboxRect.right() - inset,
                                                   bboxRect.bottom() - inset); break;
            case Rotation::West:  center = QPointF(bboxRect.left() + inset,
                                                   bboxRect.bottom() - inset); break;
            case Rotation::North: center = QPointF(bboxRect.left() + inset,
                                                   bboxRect.top() + inset); break;
            }
            painter.setBrush(statusLightFill(*status, m_visuals.statusLight));
            painter.setPen(QPen(m_visuals.statusLight.outline, 1));
            painter.drawEllipse(center, r, r);
        }
    }

    painter.setOpacity(0.5);
    for (const ConstructionSite& s : getAllSites(m_sim.getFactoryState()))
    {
        const std::map<BuildingType, BuildingVisuals>::const_iterator it =
            m_visuals.buildings.find(s.type);
        if (it == m_visuals.buildings.end()) { continue; }
        const BuildingVisuals& bv = it->second;

        for (const QPoint& cell : s.bodyCells)
        {
            painter.fillRect(coordinates.tileRect(cell), bv.fill);
        }

        const QPointF tl      = coordinates.tileToWidget(s.anchor);
        const QRectF  bboxRect(tl.x(), tl.y(),
            s.footprint.width()  * static_cast<qreal>(coordinates.getTilePx()),
            s.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
        painter.setPen(QPen(bv.outline, 1, Qt::DashLine));
        painter.setBrush(Qt::NoBrush);
        painter.drawRect(bboxRect);

        const BuildingDef* siteDef = m_sim.getConfig().buildings.findBuildingDef(s.type);
        if (siteDef)
        {
            // Glyph + progress percentage
            const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds);
            int pct = 0;
            if (s.completesAt > 0 && durationTicks > 0)
            {
                const Tick elapsed = m_sim.getCurrentTick()
                    - (s.completesAt - durationTicks);
                pct = static_cast<int>(
                    std::max(Tick(0), std::min(durationTicks, elapsed))
                    * 100 / durationTicks);
            }
            const QString pctText = QString::number(pct) + "%";

            painter.setPen(bv.outline);
            // Identity symbol with the progress percentage below it
            // (REQ-UI-CONSTRUCTION-PROGRESS): the world icon centered on the
            // footprint where the type has one, otherwise the text glyph.
            if (drawBuildingIcon(painter, coordinates, s.type, bboxRect, bv.fill))
            {
                painter.drawText(bboxRect, Qt::AlignHCenter | Qt::AlignBottom, pctText);
            }
            else if (!bv.glyph.isEmpty())
            {
                const QRectF topHalf(bboxRect.x(), bboxRect.y(),
                                     bboxRect.width(), bboxRect.height() * 0.5);
                const QRectF botHalf(bboxRect.x(),
                                     bboxRect.y() + bboxRect.height() * 0.5,
                                     bboxRect.width(), bboxRect.height() * 0.5);
                painter.drawText(topHalf, Qt::AlignCenter, bv.glyph);
                painter.drawText(botHalf, Qt::AlignCenter, pctText);
            }
            else
            {
                painter.drawText(bboxRect, Qt::AlignCenter, pctText);
            }

            // Port glyphs
            const ParsedSurfaceMask siteMask =
                parseSurfaceMask(siteDef->surfaceMask, s.rotation);
            for (const Port& port : siteMask.outputPorts)
            {
                const QPoint absBody = s.anchor
                    + outputBodyTile(port.tile, port.direction);
                drawPortGlyph(painter, coordinates, absBody, port.direction,
                              bv.outline, /*centered*/ false);
            }
        }
    }
    painter.setOpacity(1.0);

    // HP bar below the HQ footprint; the HQ's HP lives on its proxy entity. Drawn
    // after every building and construction site fill so a belt (or other tile)
    // placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT
    // neighbours case, same rationale as the selection highlights below).
    for (const Building& b : getAllBuildings(m_sim.getFactoryState()))
    {
        if (b.type != BuildingType::Hq) { continue; }
        const QPointF tl = coordinates.tileToWidget(b.anchor);
        const QRectF bboxRect(tl.x(), tl.y(),
            b.footprint.width()  * static_cast<qreal>(coordinates.getTilePx()),
            b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
        m_sim.getAdmin().forEach<HqProxyComponent, FactionComponent, HealthComponent>(
            [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/,
                const FactionComponent& f, const HealthComponent& h)
            {
                if (h.maxHp > 0.0f)
                {
                    drawHealthBar(painter, coordinates,
                              bboxRect.left(), bboxRect.bottom() + 1.0,
                              bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
                }
            });
    }

    // Selection highlights are drawn last, after every building and construction
    // site fill, so a selected building surrounded by neighbours keeps its outline:
    // the highlight sits 1px outside the footprint (into adjacent tiles), and drawing
    // it inline would let later-drawn neighbours overpaint it with their body fill.
    drawSelectionHighlights(painter, coordinates, frame);
}

void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates,
                                            const WorldRenderFrame& frame)
{
    painter.setPen(QPen(m_visuals.overlays.selectedOutline, 2));
    painter.setBrush(Qt::NoBrush);

    for (BuildingId selId : frame.selection.getSelectedBuildings())
    {
        const std::optional<QRectF> rect =
            getBuildingWidgetRect(m_sim.getFactoryState(), coordinates, selId);
        if (!rect.has_value()) { continue; }
        // Outline sits 1px outside the footprint (into adjacent tiles).
        painter.drawRect(rect->adjusted(-1, -1, 1, 1));
    }

    // A ring around each selected piece of debris, sitting just outside the debris's
    // own rendered circle (REQ-UI-DEBRIS-CLICK-SELECT).
    if (!frame.selection.getSelectedDebris().empty())
    {
        const qreal outlineRadius =
            static_cast<qreal>(getDebrisRadiusPx(coordinates)) + 3.0;
        for (const DebrisInfo& debris : getAllDebrisInfo(m_sim.getAdmin()))
        {
            if (!frame.selection.isDebrisSelected(debris.entity)) { continue; }
            painter.drawEllipse(coordinates.worldToWidget(debris.position),
                                outlineRadius, outlineRadius);
        }
    }
}

void WorldRenderer::drawPortItems(QPainter& painter, const WorldCoordinates& coordinates,
                                  const WorldRenderFrame& /*frame*/)
{
    const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f;

    // Port items are drawn over the buildings (drawBuildings runs first) but clipped
    // to a thin margin at each machine's edges: the clip region is the whole view
    // minus every machine's interior (its footprint inset by kPortMarginTiles). So a
    // transiting item shows only near the port edge — appearing to emerge from / sink
    // into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying
    // visible in the ~2×margin band at a seam between two touching buildings
    // (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so
    // items on belts stay fully visible.
    constexpr double kPortMarginTiles = 0.2;
    const double margin = kPortMarginTiles * static_cast<double>(coordinates.getTilePx());

    QRegion clip(painter.viewport());
    for (const Building& b : getAllBuildings(m_sim.getFactoryState()))
    {
        if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter
            || b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit)
        {
            continue;
        }
        const std::set<QPoint, QPointCompare> cells(b.bodyCells.begin(), b.bodyCells.end());
        for (const QPoint& cell : b.bodyCells)
        {
            // Inset an edge only where the neighbouring cell is not part of the same
            // building, so interior cell seams stay filled (handles L-shaped footprints).
            const double l = cells.count(cell + QPoint(-1,  0)) ? 0.0 : margin;
            const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin;
            const double r = cells.count(cell + QPoint( 1,  0)) ? 0.0 : margin;
            const double d = cells.count(cell + QPoint( 0,  1)) ? 0.0 : margin;
            clip = clip.subtracted(
                QRegion(coordinates.tileRect(cell).adjusted(l, t, -r, -d).toRect()));
        }
    }

    // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile colored square carrying the
    // item's icon (REQ-UI-ITEM-ICON), via the same draw path.
    const std::function<void(const ItemType&, QPointF)> drawItem =
        [&](const ItemType& type, QPointF worldPos)
    {
        const QPointF center = coordinates.worldToWidget(
            QVector2D(static_cast<float>(worldPos.x()),
                      static_cast<float>(worldPos.y())));
        drawWorldItem(painter, type.id, center, halfPx);
    };

    painter.save();
    painter.setClipRegion(clip);
    forEachEmergingItem(m_sim.getFactoryState(), drawItem);
    forEachIncomingItem(m_sim.getFactoryState(), drawItem);
    painter.restore();
}

void WorldRenderer::drawWorldItem(QPainter& painter, const std::string& itemId,
                                  QPointF center, float halfPx)
{
    if (!m_itemIcons) { return; }

    // The colored square carrying the item's icon (REQ-GW-TILE-SIZE, REQ-UI-ITEM-ICON).
    // The composition lives in the icon cache, which draws it the same way here and in
    // the UI's item displays, so an item reads the same on a belt as in a panel.
    const QRectF itemRect(center.x() - halfPx, center.y() - halfPx,
                          halfPx * 2, halfPx * 2);
    m_itemIcons->paintItem(painter, itemRect, itemId);
}

void WorldRenderer::drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates,
                                  const WorldRenderFrame& /*frame*/)
{
    const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f;
    const QRect vr     = coordinates.getViewportRect();

    m_sim.getBelts().forEachVisualItem(vr, [&](const VisualItem& vi)
    {
        const QPointF center = coordinates.worldToWidget(
            QVector2D(static_cast<float>(vi.worldPos.x()),
                      static_cast<float>(vi.worldPos.y())));
        drawWorldItem(painter, vi.type.id, center, halfPx);
    });
}

void WorldRenderer::drawDebris(QPainter& painter, const WorldCoordinates& coordinates,
                               const WorldRenderFrame& /*frame*/)
{
    for (const DebrisInfo& debris : getAllDebrisInfo(m_sim.getAdmin()))
    {
        drawDebrisMarker(painter, coordinates,
                         coordinates.worldToWidget(debris.position));
    }
}

void WorldRenderer::drawStations(QPainter& painter, const WorldCoordinates& coordinates,
                                 const WorldRenderFrame& frame)
{
    m_sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
        [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f,
            const HealthComponent& h)
        {
            const BuildingType visType = f.isEnemy
                ? BuildingType::EnemyDefenceStation
                : BuildingType::PlayerDefenceStation;
            const std::map<BuildingType, BuildingVisuals>::const_iterator it =
                m_visuals.buildings.find(visType);
            if (it == m_visuals.buildings.end()) { return; }
            const BuildingVisuals& bv = it->second;

            painter.setPen(Qt::NoPen);
            for (const QPoint& cell : sb.bodyCells)
            {
                painter.fillRect(coordinates.tileRect(cell), bv.fill);
            }

            const QPointF tl =
                coordinates.tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y()));
            const QRectF bboxRect(tl.x(), tl.y(),
                sb.footprint.width()  * static_cast<qreal>(coordinates.getTilePx()),
                sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));

            painter.setPen(QPen(bv.outline, 1));
            painter.setBrush(Qt::NoBrush);
            painter.drawRect(bboxRect);

            // Station icon over the fill (REQ-UI-WORLD-ICON); the same symbol is
            // colored blue/red by the player/enemy fill it is drawn over.
            drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill);

            if (frame.selection.isActorSelected(e))
            {
                painter.setPen(QPen(m_visuals.overlays.selectedOutline, 2));
                painter.setBrush(Qt::NoBrush);
                painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2));
            }

            // HP bar below footprint.
            if (h.maxHp > 0.0f)
            {
                drawHealthBar(painter, coordinates,
                          bboxRect.left(), bboxRect.bottom() + 1.0,
                          bboxRect.width(), h.hp / h.maxHp, f.isEnemy);
            }
        });
}

void WorldRenderer::drawShips(QPainter& painter, const WorldCoordinates& coordinates,
                              const WorldRenderFrame& frame)
{
    const float forward = getShipForwardExtentPx(coordinates);

    m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
                           FactionComponent, HealthComponent>(
        [&](entt::entity e, const ShipIdentityComponent& si,
            const PositionComponent& pos, const FacingComponent& facing,
            const FactionComponent& fac, const HealthComponent& h)
        {
            const std::map<std::string, ShipVisuals>::const_iterator it =
                m_visuals.ships.find(si.schematicId);
            if (it == m_visuals.ships.end()) { return; }

            const QPointF center = coordinates.worldToWidget(pos.value);
            drawShipBody(painter, coordinates, center, facing.radians,
                         it->second.fill, it->second.outline);

            if (frame.selection.isActorSelected(e))
            {
                painter.setPen(QPen(m_visuals.overlays.selectedOutline, 2));
                painter.setBrush(Qt::NoBrush);
                const qreal r = static_cast<qreal>(forward) + 2.0;
                painter.drawEllipse(center, r, r);
            }

            if (h.maxHp > 0.0f)
            {
                const qreal barW = static_cast<qreal>(forward) * 2.0;
                const qreal barX = center.x() - static_cast<qreal>(forward);
                const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
                drawHealthBar(painter, coordinates, barX, barY, barW,
                              h.hp / h.maxHp, fac.isEnemy);
            }
        });
}

void WorldRenderer::drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates,
                                          const WorldRenderFrame& /*frame*/)
{
    m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
                           FactionComponent, SensorRangeComponent>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const FacingComponent& /*facing*/,
            const FactionComponent& /*fac*/, const SensorRangeComponent& sensor)
        {
            const std::map<std::string, ShipVisuals>::const_iterator it =
                m_visuals.ships.find(si.schematicId);
            if (it == m_visuals.ships.end()) { return; }

            drawSensorRange(painter, coordinates,
                            coordinates.worldToWidget(pos.value),
                            sensor.value_tiles, it->second.outline);
        });
}

void WorldRenderer::drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates,
                                         const WorldRenderFrame& /*frame*/)
{
    // Draw a thin translucent line from a ship to a target, colored by the ship's
    // own schematic fill. Shared by the attack, repair and salvage target lines.
    const std::function<void(const std::string&, const QVector2D&, const QVector2D&)>
        drawTargetLine = [&](const std::string& schematicId, const QVector2D& from,
                             const QVector2D& to)
    {
        const std::map<std::string, ShipVisuals>::const_iterator it =
            m_visuals.ships.find(schematicId);
        if (it == m_visuals.ships.end()) { return; }

        QColor lineColor = it->second.fill;
        lineColor.setAlpha(128);
        painter.setPen(QPen(lineColor, 1));
        painter.drawLine(coordinates.worldToWidget(from),
                         coordinates.worldToWidget(to));
    };

    m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, AttackBehavior>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const AttackBehavior& attack)
        {
            if (!attack.currentTarget.has_value()) { return; }

            const std::optional<QVector2D> targetPos =
                entityPosition(*attack.currentTarget);
            if (!targetPos.has_value()) { return; }

            drawTargetLine(si.schematicId, pos.value, *targetPos);
        });

    m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, RepairBehavior>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const RepairBehavior& repair)
        {
            if (!repair.currentTarget.has_value()) { return; }

            const std::optional<QVector2D> targetPos =
                entityPosition(*repair.currentTarget);
            if (!targetPos.has_value()) { return; }

            drawTargetLine(si.schematicId, pos.value, *targetPos);
        });

    m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, SalvageScrapBehavior>(
        [&](entt::entity /*e*/, const ShipIdentityComponent& si,
            const PositionComponent& pos, const SalvageScrapBehavior& salvage)
        {
            if (!salvage.debrisTarget.has_value()) { return; }

            drawTargetLine(si.schematicId, pos.value, *salvage.debrisTarget);
        });
}

void WorldRenderer::drawBeams(QPainter& painter, const WorldCoordinates& coordinates,
                              const WorldRenderFrame& frame)
{
    const QPainter::RenderHints savedHints = painter.renderHints();
    painter.setRenderHint(QPainter::Antialiasing, true);

    for (const ActiveBeam& beam : frame.beams)
    {
        const std::optional<QVector2D> shooterPos = entityPosition(beam.event.shooter);
        const std::optional<QVector2D> targetPos  = entityPosition(beam.event.target);
        if (!shooterPos.has_value() || !targetPos.has_value()) { continue; }

        QColor color = m_visuals.beams.weaponColor;
        switch (beam.event.kind)
        {
            case BeamKind::Weapon:  color = m_visuals.beams.weaponColor;  break;
            case BeamKind::Repair:  color = m_visuals.beams.repairColor;  break;
            case BeamKind::Salvage: color = m_visuals.beams.salvageColor; break;
        }

        const QPointF s = coordinates.worldToWidget(*shooterPos);
        const QPointF t = coordinates.worldToWidget(*targetPos + beam.targetOffset);

        // Unit direction/perpendicular of the beam in widget space. A degenerate
        // zero-length beam (shooter and target coincide) has no direction to
        // taper along, so skip it.
        const QVector2D delta(static_cast<float>(t.x() - s.x()),
                              static_cast<float>(t.y() - s.y()));
        const float lengthPx = delta.length();
        if (lengthPx < 0.001f) { continue; }
        const QVector2D dir  = delta / lengthPx;
        const QVector2D perp(-dir.y(), dir.x());

        // Directional taper: draw the beam as a quad that is wide at the shooter
        // and narrows to a faint tip at the target, so it reads as an arrow
        // pointing away from whoever fired it. Without this, a beam strung
        // between two nearby ships is symmetric and gives no cue which end is the
        // source (the readability problem this addresses).
        const float widthPx  = std::max(1.0f, static_cast<float>(m_visuals.beams.widthPx));
        const float baseHalf = widthPx * 1.f;
        const float tipHalf  = widthPx * 0.35f;

        QPolygonF quad;
        quad << QPointF(s.x() + static_cast<qreal>(perp.x() * baseHalf),
                        s.y() + static_cast<qreal>(perp.y() * baseHalf))
             << QPointF(s.x() - static_cast<qreal>(perp.x() * baseHalf),
                        s.y() - static_cast<qreal>(perp.y() * baseHalf))
             << QPointF(t.x() - static_cast<qreal>(perp.x() * tipHalf),
                        t.y() - static_cast<qreal>(perp.y() * tipHalf))
             << QPointF(t.x() + static_cast<qreal>(perp.x() * tipHalf),
                        t.y() + static_cast<qreal>(perp.y() * tipHalf));

        QColor bright = color;
        bright.setAlpha(255);
        QColor faint = color;
        faint.setAlpha(90);

        QLinearGradient bodyGrad(s, t);
        bodyGrad.setColorAt(0.0, bright);
        bodyGrad.setColorAt(1.0, faint);

        painter.setPen(Qt::NoPen);
        painter.setBrush(bodyGrad);
        painter.drawPolygon(quad);
    }

    painter.setBrush(Qt::NoBrush);
    painter.setRenderHints(savedHints);
}

void WorldRenderer::drawSelectedTunnelConnections(QPainter& painter, const WorldCoordinates& coordinates,
                                                  const WorldRenderFrame& frame)
{
    if (frame.selection.getSelectedBuildings().empty()) { return; }

    const TunnelTileMap tunnels = collectTunnelTiles(m_sim.getFactoryState());
    if (tunnels.empty()) { return; }

    const TunnelLookup lookup = makeTunnelLookup(tunnels);

    // Collect the tiles to highlight in a set so a connection selected from both ends
    // (or overlapping runs) is filled exactly once — filling a semi-transparent green
    // twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
    std::set<QPoint, QPointCompare> highlightTiles;
    for (const BuildingId id : frame.selection.getSelectedBuildings())
    {
        std::optional<QPoint>   anchor;
        std::optional<BuildingType> type;
        Rotation                rotation = Rotation::East;
        if (const Building* b = findBuilding(m_sim.getFactoryState(), id))
        {
            anchor = b->anchor; type = b->type; rotation = b->rotation;
        }
        else if (const ConstructionSite* s = findSite(m_sim.getFactoryState(), id))
        {
            anchor = s->anchor; type = s->type; rotation = s->rotation;
        }
        if (!type.has_value()
            || (*type != BuildingType::TunnelEntry && *type != BuildingType::TunnelExit))
        {
            continue;
        }

        const std::optional<QPoint> partner = findTunnelPartner(
            lookup, *anchor, *type, rotation, m_sim.getConfig().world.tunnelMaxDistance_tiles);
        if (!partner.has_value()) { continue; }

        // Add every tile from the selected end to its partner inclusive (colinear run).
        const QPoint delta = *partner - *anchor;
        const QPoint stepDir((delta.x() > 0) - (delta.x() < 0),
                             (delta.y() > 0) - (delta.y() < 0));
        for (QPoint t = *anchor; ; t += stepDir)
        {
            highlightTiles.insert(t);
            if (t == *partner) { break; }
        }
    }

    const QColor green = m_visuals.overlays.tunnelPreview;
    for (const QPoint& tile : highlightTiles)
    {
        painter.fillRect(coordinates.tileRect(tile), green);
    }
}

void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coordinates,
                                 const WorldRenderFrame& frame)
{
    // Green connection highlight for any selected tunnel end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
    drawSelectedTunnelConnections(painter, coordinates, frame);

    // Builder-mode ghost
    if (frame.buildMode.isBuilderMode())
    {
        if (frame.buildMode.getBuilderType() == BuildingType::Belt
            && frame.buildMode.isDraggingBelt())
        {
            // Belt drag: a ghost per path tile (REQ-BLD-BELT-DRAG). Rotate-in-place
            // and affordable new tiles use the belt colors; occupied/invalid tiles
            // use the invalid color; unaffordable tiles show no ghost at all.
            const std::vector<BeltDragResolved> resolved = resolveBeltDragPath(frame.buildMode.getBeltDragPath(), m_sim.getFactoryState(), m_sim.getConfig(), m_sim.getBuildingBlocksStock());
            for (std::size_t index = 0; index < resolved.size(); ++index)
            {
                const BeltDragResolved& item = resolved[index];
                if (item.action == BeltTileAction::PlaceNew && !item.affordable)
                {
                    continue;
                }
                const BeltPathTile& entry = frame.buildMode.getBeltDragPath()[index];
                drawBuildingGhost(painter, coordinates, BuildingType::Belt,
                                  entry.tile, entry.rotation,
                                  item.action != BeltTileAction::Invalid
                                      ? GhostTint::Normal : GhostTint::Invalid,
                                  /*showPortTargetGlyphs*/ true);
            }
        }
        // A cursor that points at no tile — resting on a floating panel, or outside
        // the window — hovers nothing, and builder mode then shows no ghost at all
        // (REQ-BLD-GHOST).
        else if (frame.buildMode.getGhostTile().has_value())
        {
            // In tunnel mode the ghost shows the position-resolved type (entry or
            // exit) and, when it would complete an existing tunnel, the matched end
            // and the tiles between it and the ghost are tinted green
            // (REQ-BLD-TUNNEL-MODE).
            const QPoint                 ghostTile   = *frame.buildMode.getGhostTile();
            const std::optional<QPoint>& partnerTile = frame.buildMode.getTunnelPartnerTile();
            if (frame.buildMode.isTunnelMode() && frame.buildMode.isGhostValid()
                && partnerTile.has_value())
            {
                const QColor green = m_visuals.overlays.tunnelPreview;
                painter.fillRect(coordinates.tileRect(*partnerTile), green);

                // Partner and ghost tile are colinear along the tunnel run; tint the
                // tiles strictly between them.
                const QPoint delta = *partnerTile - ghostTile;
                const QPoint step((delta.x() > 0) - (delta.x() < 0),
                                  (delta.y() > 0) - (delta.y() < 0));
                for (QPoint t = ghostTile + step; t != *partnerTile; t += step)
                {
                    painter.fillRect(coordinates.tileRect(t), green);
                }
            }

            drawBuildingGhost(painter, coordinates,
                              frame.buildMode.getEffectiveBuilderType(),
                              ghostTile, frame.buildMode.getGhostRotation(),
                              frame.buildMode.isGhostValid()
                                  ? GhostTint::Normal : GhostTint::Invalid,
                              /*showPortTargetGlyphs*/ true);
        }
    }

    // Blueprint placement ghost, drawn only while the cursor points at a tile, as for
    // the builder ghost above (REQ-BLD-GHOST).
    if (frame.buildMode.isBlueprintMode()
        && frame.buildMode.getBlueprintGhostTile().has_value())
    {
        // A single-building blueprint hit-tests the cursor for its transfer target; a
        // constellation does not (REQ-UI-BLUEPRINT-TRANSFER). The stored building count,
        // not the count after locked types are dropped, so the rule does not shift as the
        // player unlocks things.
        const QPoint cursorTile = *frame.buildMode.getBlueprintGhostTile();
        const std::optional<QPoint> hoverTile =
            frame.buildMode.getBlueprint().buildings.size() == 1
                ? std::make_optional(cursorTile) : std::nullopt;
        for (const BlueprintBuilding& bb : frame.buildMode.getBlueprint().buildings)
        {
            // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING,
            // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either.
            if (!m_sim.isBuildingUnlocked(bb.type)) { continue; }
            // The same classifier the click path uses, so the color always predicts what
            // clicking would do (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). A
            // compatible overlap is an ordinary valid ghost. The resolved anchor and
            // rotation are drawn rather than the blueprint's own, so a hovered transfer
            // target shows the ghost snapped onto it.
            const BlueprintGhostResolved resolved = resolveBlueprintGhost(
                m_sim.getFactoryState(), m_sim.getConfig(), bb.type, cursorTile + bb.offset,
                bb.rotation, hoverTile);
            GhostTint tint = GhostTint::Normal;
            if (resolved.action == BlueprintGhostAction::Transfer)
            {
                tint = GhostTint::Transfer;
            }
            else if (resolved.action == BlueprintGhostAction::Invalid)
            {
                tint = GhostTint::Invalid;
            }
            drawBuildingGhost(painter, coordinates, bb.type, resolved.ghostAnchor,
                              resolved.ghostRotation, tint,
                              /*showPortTargetGlyphs*/ false);
        }
    }

    // Queued for deconstruction: tint every building currently in the
    // deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE).
    for (const Building& b : getAllBuildings(m_sim.getFactoryState()))
    {
        if (!b.queuedForDeconstruction) { continue; }
        for (const QPoint& cell : b.bodyCells)
        {
            painter.fillRect(coordinates.tileRect(cell),
                             m_visuals.overlays.deconstructTint);
        }
    }

    // Deconstruct tint: while dragging a deconstruct box, tint every covered
    // building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one.
    if (frame.buildMode.isDeconstructMode() && frame.boxWorldRect.has_value())
    {
        for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), *frame.boxWorldRect))
        {
            const Building* b = findBuilding(m_sim.getFactoryState(), id);
            if (b && b->type == BuildingType::Hq) { continue; }
            const std::vector<QPoint>* cells = nullptr;
            const ConstructionSite* s = nullptr;
            if (b) { cells = &b->bodyCells; }
            else if ((s = findSite(m_sim.getFactoryState(), id))) { cells = &s->bodyCells; }
            if (cells)
            {
                for (const QPoint& cell : *cells)
                {
                    painter.fillRect(coordinates.tileRect(cell),
                                     m_visuals.overlays.deconstructTint);
                }
            }
        }
    }
    else if (frame.buildMode.isDeconstructMode()
             && frame.buildMode.getDeconstructHoverBuildingId().has_value())
    {
        const Building* b = findBuilding(m_sim.getFactoryState(),
                                         *frame.buildMode.getDeconstructHoverBuildingId());
        if (b)
        {
            for (const QPoint& cell : b->bodyCells)
            {
                painter.fillRect(coordinates.tileRect(cell),
                                 m_visuals.overlays.deconstructTint);
            }
        }
    }

    // Box-select rectangle, drawn from the world rectangle itself and unsnapped, so
    // the outline sits where the mouse went rather than on the tile grid
    // (REQ-UI-MULTI-SELECT).
    if (frame.boxWorldRect.has_value())
    {
        const QRectF selRect(
            coordinates.worldToWidget(QVector2D(frame.boxWorldRect->topLeft())),
            coordinates.worldToWidget(QVector2D(frame.boxWorldRect->bottomRight())));
        // In deconstruct mode the box marks buildings for demolition, so it is
        // drawn in the deconstruct red instead of the selection color; the
        // tint's alpha governs only the fills it tints, never this outline
        // (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX).
        QColor rectColor = m_visuals.overlays.selectedOutline;
        if (frame.buildMode.isDeconstructMode())
        {
            rectColor = m_visuals.overlays.deconstructTint;
            rectColor.setAlpha(255);
        }
        painter.setPen(QPen(rectColor, 1));
        painter.setBrush(Qt::NoBrush);
        painter.drawRect(selRect);
    }
}

void WorldRenderer::drawBuildingGhost(QPainter& painter,
                                      const WorldCoordinates& coordinates,
                                      BuildingType type,
                                      QPoint anchorTile, Rotation rotation,
                                      GhostTint tint, bool showPortTargetGlyphs)
{
    const BuildingDef* def = m_sim.getConfig().buildings.findBuildingDef(type);
    if (!def) { return; }

    const std::map<BuildingType, BuildingVisuals>::const_iterator it =
        m_visuals.buildings.find(type);
    if (it == m_visuals.buildings.end()) { return; }
    const BuildingVisuals& bv = it->second;

    // Normal ghosts show the building type's own colors; the other two tints override
    // them with a single flat color -- invalid (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID) or
    // configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). An override's RGB is taken at
    // full opacity so it does not double-dim against the setOpacity below (the
    // configured color carries its own alpha).
    const QColor& configured = tint == GhostTint::Transfer
                                   ? m_visuals.overlays.configTransfer
                                   : m_visuals.overlays.ghostInvalid;
    const QColor overrideColor(configured.red(), configured.green(), configured.blue());
    const bool   useOwnColors = tint == GhostTint::Normal;
    const QColor fillColor = useOwnColors ? bv.fill    : overrideColor;
    const QColor lineColor = useOwnColors ? bv.outline : overrideColor;

    const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
    if (parsed.bodyCells.empty()) { return; }

    painter.setOpacity(0.5);

    QPoint minCell = parsed.bodyCells.front();
    QPoint maxCell = parsed.bodyCells.front();
    for (const QPoint& cell : parsed.bodyCells)
    {
        painter.fillRect(coordinates.tileRect(anchorTile + cell), fillColor);
        minCell.setX(std::min(minCell.x(), cell.x()));
        minCell.setY(std::min(minCell.y(), cell.y()));
        maxCell.setX(std::max(maxCell.x(), cell.x()));
        maxCell.setY(std::max(maxCell.y(), cell.y()));
    }

    const QPointF tl = coordinates.tileToWidget(anchorTile + minCell);
    const QRectF  bboxRect(tl.x(), tl.y(),
        (maxCell.x() - minCell.x() + 1) * static_cast<qreal>(coordinates.getTilePx()),
        (maxCell.y() - minCell.y() + 1) * static_cast<qreal>(coordinates.getTilePx()));

    painter.setPen(QPen(lineColor, 1));
    painter.setBrush(Qt::NoBrush);
    painter.drawRect(bboxRect);

    // Icon glyph over the ghost fill (REQ-UI-WORLD-ICON); an invalid ghost's fill
    // is the red invalid color, which auto-contrasts to a white icon.
    if (!drawBuildingIcon(painter, coordinates, type, bboxRect, fillColor)
        && !bv.glyph.isEmpty())
    {
        painter.setPen(lineColor);
        painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
    }

    for (const Port& port : parsed.outputPorts)
    {
        drawPortGlyph(painter, coordinates,
                      anchorTile + outputBodyTile(port.tile, port.direction),
                      port.direction, lineColor, /*centered*/ false);
    }

    // REQ-UI-PORT-TARGET-GLYPH: while in builder mode, additionally mark each
    // output port's target cell (the cell just outside the footprint the port
    // pushes into) with a directional glyph, previewing where output will flow.
    // The Tunnel Entry is excluded — it receives items from any of its non-mouth
    // edges rather than emitting into a single adjacent cell. The Shipyard is
    // excluded too — it spawns a ship rather than emitting a belt item.
    if (showPortTargetGlyphs
        && type != BuildingType::TunnelEntry
        && type != BuildingType::Shipyard)
    {
        for (const Port& port : parsed.outputPorts)
        {
            drawPortGlyph(painter, coordinates, anchorTile + port.tile,
                          port.direction, lineColor, /*centered*/ true);
        }
    }

    painter.setOpacity(1.0);
}
