diff --git a/src/ui/ItemIconCache.cpp b/src/ui/ItemIconCache.cpp index 9981e22..7ae6b41 100644 --- a/src/ui/ItemIconCache.cpp +++ b/src/ui/ItemIconCache.cpp @@ -2,10 +2,26 @@ #include #include +#include #include -ItemIconCache::ItemIconCache(const QString& iconDir) +#include "VisualsConfig.h" + +namespace +{ + +// How far the icon is inset within the item's colored square, as a fraction of the +// square's size on each side (REQ-UI-ITEM-ICON). The inset is what keeps a frame of the +// square's color visible all around the icon: each icon's viewBox is cropped tight to +// its artwork, so an icon drawn at the full rect would cover the square entirely. +const double kIconInsetFraction = 0.15; + +} // namespace + + +ItemIconCache::ItemIconCache(const QString& iconDir, const VisualsConfig* visuals) : m_iconDir(iconDir) + , m_visuals(visuals) { } @@ -34,14 +50,59 @@ bool ItemIconCache::hasIcon(const std::string& itemId) return !getSvg(itemId).isEmpty(); } +void ItemIconCache::paintItem(QPainter& painter, const QRectF& rect, + const std::string& itemId) +{ + // The colored square from visuals.toml backs every item, icon or not: it is what + // gives the item contrast against the tile beneath it in the world, and its outline + // is what separates neighbouring items where they overlap on a belt (REQ-GW-TILE-SIZE, + // REQ-UI-ITEM-ICON). + if (m_visuals != nullptr) + { + const std::map::const_iterator it = + m_visuals->items.find(itemId); + if (it != m_visuals->items.end()) + { + painter.fillRect(rect, it->second.fill); + painter.setPen(QPen(it->second.outline, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(rect); + } + } + + if (!hasIcon(itemId)) { return; } + + // The icon goes on top, inset so a frame of the square's color stays visible all + // around it. It is rasterized once at the inset pixel size and cached, so this is a + // plain pixmap blit per frame. + const double inset = kIconInsetFraction * rect.width(); + const QRectF iconRect = rect.adjusted(inset, inset, -inset, -inset); + + int sizePx = qRound(iconRect.width()); + if (sizePx < 1) { sizePx = 1; } + painter.drawPixmap(iconRect, getPixmap(itemId, sizePx), + QRectF(0, 0, sizePx, sizePx)); +} + +QPixmap ItemIconCache::getSquarePixmap(const std::string& itemId, int sizePx) +{ + return getPixmap("square:" + itemId, itemId, sizePx, true); +} + QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx) +{ + return getPixmap(itemId, itemId, sizePx, false); +} + +QPixmap ItemIconCache::getPixmap(const std::string& cacheKey, const std::string& itemId, + int sizePx, bool withSquare) { if (sizePx <= 0) { return QPixmap(); } - const std::pair key(itemId, sizePx); + const std::pair key(cacheKey, sizePx); const std::map, QPixmap>::const_iterator cached = m_pixmapCache.find(key); if (cached != m_pixmapCache.end()) @@ -49,16 +110,45 @@ QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx) return cached->second; } - const QByteArray& svg = getSvg(itemId); QPixmap pixmap; - if (!svg.isEmpty()) + if (withSquare) { - QSvgRenderer renderer(svg); - pixmap = QPixmap(sizePx, sizePx); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, true); - renderer.render(&painter); + // Null unless the item has something to draw -- a square, an icon, or both -- + // so a caller with nothing to show can fall back to text (REQ-UI-RECIPE-ICON). + const bool hasSquare = m_visuals != nullptr + && m_visuals->items.find(itemId) != m_visuals->items.end(); + if (hasSquare || hasIcon(itemId)) + { + pixmap = QPixmap(sizePx, sizePx); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + // No antialiasing: the square's edges are axis-aligned and land on pixel + // boundaries, and smoothing a 1-pixel outline only blurs it. The icon is + // blitted into a slightly smaller rect than it was rasterized at, which is + // what the smooth transform is for. + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + // One pixel short of the pixmap so the square's right and bottom edges land + // inside it rather than on its border. + paintItem(painter, QRectF(0, 0, sizePx - 1, sizePx - 1), itemId); + } + } + else + { + const QByteArray& svg = getSvg(itemId); + if (!svg.isEmpty()) + { + QSvgRenderer renderer(svg); + pixmap = QPixmap(sizePx, sizePx); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + } } return m_pixmapCache.emplace(key, std::move(pixmap)).first->second; } + +void ItemIconCache::clearPixmapCache() +{ + m_pixmapCache.clear(); +} diff --git a/src/ui/ItemIconCache.h b/src/ui/ItemIconCache.h index 9eaf904..6f2dc5a 100644 --- a/src/ui/ItemIconCache.h +++ b/src/ui/ItemIconCache.h @@ -8,38 +8,76 @@ #include #include -// Rasterizes and caches per-item icon SVGs (REQ-UI-ITEM-ICON). Item icons are -// self-contained, full-color SVGs loaded from a directory, one file per item type -// named after the item's id (e.g. "iron_ore.svg"). Shared by the recipe-selection -// dialog (REQ-UI-RECIPE-ICON) and the game world's belt/port item rendering so the -// rasterization is not duplicated. +class QPainter; +class QRectF; +struct VisualsConfig; + +// Rasterizes and caches per-item icon SVGs, and composes them onto the item's colored +// square (REQ-UI-ITEM-ICON). Item icons are self-contained, full-color SVGs loaded from +// a directory, one file per item type named after the item's id (e.g. "iron_ore.svg"). // -// A missing icon file is not an error: hasIcon() returns false for it and the caller -// falls back (a colored square in the world, a name caption in the dialog). +// The square is the item's `fill` and `outline` from visuals.toml with the icon inset +// within it, and it backs the icon both in the game world and wherever the UI displays +// an item as an item: the recipe-selection dialog's option buttons (REQ-UI-RECIPE-ICON), +// the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), and the +// recipe summary (REQ-UI-RECIPE-SUMMARY). Defined here once rather than per widget, in +// two forms: paintItem() for the world's fractional geometry, getSquarePixmap() for +// widgets that want a ready-made pixmap. +// +// The bare icon of getPixmap() has one remaining use: the inline building_block icon +// that stands in for the word "Blocks" beside a number (REQ-UI-BLOCKS-ICON), which is a +// decoration on a line of text rather than an item display and takes no square. +// +// A missing icon file is not an error: hasIcon() returns false for it and the item shows +// its colored square alone. class ItemIconCache { public: - // iconDir is the directory holding the ".svg" icon files - // (typically "/../icons/items"). - explicit ItemIconCache(const QString& iconDir); + // iconDir is the directory holding the ".svg" icon files (typically + // "/../icons/items"). visuals supplies the per-item square colors and + // must outlive the cache; its contents may be replaced on a restart (REQ-CFG-RELOAD), + // which is what clearPixmapCache() is for. + ItemIconCache(const QString& iconDir, const VisualsConfig* visuals); // True if an icon SVG file exists for the given item id. Loads the file's bytes // on first query and remembers the result (including absence) so repeated calls // are cheap. bool hasIcon(const std::string& itemId); - // Returns the item's icon rasterized to a transparent sizePx*sizePx pixmap, - // cached per (item id, size) so it is rendered once and reused across frames and - // only re-rasterized when the target size changes (REQ-UI-ITEM-ICON). Returns a - // null pixmap if the item has no icon file (callers should gate on hasIcon()). + // Paints the item's colored square into rect and its icon inset within it + // (REQ-UI-ITEM-ICON). Takes the rect as a QRectF so the world can keep painting at + // sub-pixel geometry. An item with no visuals entry gets no square, one with no icon + // file no icon; with neither, this paints nothing. + void paintItem(QPainter& painter, const QRectF& rect, const std::string& itemId); + + // The same composition rasterized to a sizePx*sizePx pixmap for widget use, cached + // per (item id, size) so it is rendered once and reused. Returns a null pixmap only + // when the item has neither a visuals entry nor an icon file, which is the one case + // in which a caller has nothing to show and falls back to text. + QPixmap getSquarePixmap(const std::string& itemId, int sizePx); + + // Returns the item's icon alone, without its square, rasterized to a transparent + // sizePx*sizePx pixmap and cached per (item id, size) so it is rendered once and + // reused across frames (REQ-UI-ITEM-ICON). Returns a null pixmap if the item has no + // icon file (callers should gate on hasIcon()). QPixmap getPixmap(const std::string& itemId, int sizePx); + // Drops every rasterized pixmap. Called when the visuals are reloaded on a restart + // (REQ-CFG-RELOAD), because the composed squares carry the colors they were painted + // with; they are re-rasterized on next use. + void clearPixmapCache(); + private: // Returns the raw SVG bytes for an item id, loading and caching them on first // access. An absent file caches an empty QByteArray so it is not retried. const QByteArray& getSvg(const std::string& itemId); + // Shared rasterize-and-cache step. cacheKey distinguishes the bare and squared + // variants of one item within the single pixmap cache. + QPixmap getPixmap(const std::string& cacheKey, const std::string& itemId, + int sizePx, bool withSquare); QString m_iconDir; + const VisualsConfig* m_visuals; // Not owned; lives in MainWindow. std::map m_svgById; std::map, QPixmap> m_pixmapCache; }; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 67ad1af..fe99cd5 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -55,7 +55,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, // REQ-UI-BUILD-ICON). const QString configDirPath = QString::fromStdString(m_configDir); m_itemIcons = std::make_unique( - QDir::cleanPath(configDirPath + "/../icons/items")); + QDir::cleanPath(configDirPath + "/../icons/items"), &m_visuals); m_buildingIcons = std::make_unique( QDir::cleanPath(configDirPath + "/../icons/buildings")); @@ -283,6 +283,9 @@ std::optional MainWindow::reloadConfig() VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); m_dimOverlay->setDimColor(m_visuals.overlays.modalDim); + // The composed item squares carry the colors they were painted with, so they + // are dropped for the new ones to take effect (REQ-UI-ITEM-ICON). + m_itemIcons->clearPixmapCache(); return newConfig; } catch (const std::exception& e) diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 9dbfe1d..096a5f4 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -127,12 +127,17 @@ RecipeSelectionDialog::RecipeSelectionDialog( const RecipeSelectionOption& option = options[static_cast(i)]; QPushButton* button = new QPushButton(this); - // Icon-only when the produced item has an icon (REQ-UI-RECIPE-ICON); otherwise - // fall back to the caption. The name stays reachable via the tooltip. - if (!option.iconItemId.empty() && itemIcons->hasIcon(option.iconItemId)) + // Icon-only for a recipe option: the produced item's icon on its colored square, + // or the square alone when the item has no icon file (REQ-UI-RECIPE-ICON). The + // name stays reachable via the tooltip. Options carrying no item at all -- the + // "(None)" entry and the Shipyard's schematics -- keep their text caption, as + // does an item with neither square nor icon to show. + const QPixmap icon = option.iconItemId.empty() + ? QPixmap() + : itemIcons->getSquarePixmap(option.iconItemId, kOptionIconSize.width()); + if (!icon.isNull()) { - button->setIcon(QIcon(itemIcons->getPixmap( - option.iconItemId, kOptionIconSize.width()))); + button->setIcon(QIcon(icon)); button->setIconSize(kOptionIconSize); } else diff --git a/src/ui/WorldRenderer.cpp b/src/ui/WorldRenderer.cpp index e783c4c..6aa8cd8 100644 --- a/src/ui/WorldRenderer.cpp +++ b/src/ui/WorldRenderer.cpp @@ -541,37 +541,14 @@ void WorldRenderer::drawPortItems(QPainter& painter, const WorldCoordinates& coo 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); - - // The colored square from visuals.toml (REQ-GW-TILE-SIZE) backs every item, icon or - // not: it is what gives the item contrast against the tile beneath it, and its dark - // outline is what separates neighbouring items where they overlap on a belt. - const std::map::const_iterator it = - m_visuals.items.find(itemId); - if (it != m_visuals.items.end()) - { - painter.fillRect(itemRect, it->second.fill); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(itemRect); - } - - if (!m_itemIcons || !m_itemIcons->hasIcon(itemId)) { return; } - - // The icon goes on top, inset so a frame of the square's color stays visible all - // around it (REQ-UI-ITEM-ICON). The inset is needed because each icon's viewBox is - // cropped tight to its artwork: drawn at the full rect, a solid icon would cover the - // square entirely. It is rasterized once at the inset pixel size and cached, so this - // is a plain pixmap blit per frame. - constexpr double kIconInsetFraction = 0.15; - const double inset = kIconInsetFraction * static_cast(halfPx * 2.0f); - const QRectF iconRect = itemRect.adjusted(inset, inset, -inset, -inset); - - int sizePx = qRound(iconRect.width()); - if (sizePx < 1) { sizePx = 1; } - painter.drawPixmap(iconRect, m_itemIcons->getPixmap(itemId, sizePx), - QRectF(0, 0, sizePx, sizePx)); + m_itemIcons->paintItem(painter, itemRect, itemId); } void WorldRenderer::drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates, diff --git a/src/ui/selection/ItemChipRow.cpp b/src/ui/selection/ItemChipRow.cpp index 7501458..471e039 100644 --- a/src/ui/selection/ItemChipRow.cpp +++ b/src/ui/selection/ItemChipRow.cpp @@ -8,8 +8,9 @@ namespace { -// Size the item icon is drawn at inside a chip, in device-independent pixels. -const int kChipIconSizePx = 18; +// Size the item's colored square is drawn at inside a chip, in device-independent +// pixels. Larger than the artwork it carries, which the square insets (REQ-UI-ITEM-ICON). +const int kChipIconSizePx = 22; // Chips per row. Two fit the panel's capped width side by side; a third would force the // counts to shrink. @@ -64,11 +65,10 @@ void ItemChipRow::rebuildChips(const std::vector& entries) for (std::size_t index = 0; index < entries.size(); ++index) { const std::string& itemId = entries[index].itemId; - // A missing icon file is not an error (REQ-UI-ITEM-ICON): the chip is then laid - // out around its count alone. - const QPixmap icon = m_itemIcons->hasIcon(itemId) - ? m_itemIcons->getPixmap(itemId, kChipIconSizePx) - : QPixmap(); + // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file + // is not an error: the chip then carries the square alone, and only an item with + // no square either leaves the chip laid out around its count alone. + const QPixmap icon = m_itemIcons->getSquarePixmap(itemId, kChipIconSizePx); ItemChip* chip = new ItemChip(icon, this); m_layout->addWidget(chip, static_cast(index) / kChipsPerRow, diff --git a/src/ui/selection/RecipeSummaryRow.cpp b/src/ui/selection/RecipeSummaryRow.cpp index ecf85b5..b308097 100644 --- a/src/ui/selection/RecipeSummaryRow.cpp +++ b/src/ui/selection/RecipeSummaryRow.cpp @@ -10,8 +10,9 @@ namespace { -// Size the item icons are drawn at on the summary line, in device-independent pixels. -const int kSummaryIconSizePx = 14; +// Size the items' colored squares are drawn at on the summary line, in device-independent +// pixels. Larger than the artwork they carry, which the square insets (REQ-UI-ITEM-ICON). +const int kSummaryIconSizePx = 18; // Adds a freshly built label to the summary and shows it. // @@ -101,13 +102,15 @@ void RecipeSummaryRow::addAmounts(const std::vector& amounts) { for (const Amount& entry : amounts) { - // A missing icon file is not an error (REQ-UI-ITEM-ICON): the item's id then - // stands in for its icon. - if (m_itemIcons->hasIcon(entry.itemId)) + // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file + // is not an error: the square stands alone then, and only an item with no square + // either falls back to its id in text. + const QPixmap icon = + m_itemIcons->getSquarePixmap(entry.itemId, kSummaryIconSizePx); + if (!icon.isNull()) { QLabel* iconLabel = new QLabel(this); - iconLabel->setPixmap( - m_itemIcons->getPixmap(entry.itemId, kSummaryIconSizePx)); + iconLabel->setPixmap(icon); addAndShow(m_layout, iconLabel); } else