draw item icons on their colored square in the UI

Item icons were drawn bare in the panels and dialogs while only the game
world composed them onto the item's visuals.toml square, so the same item
read differently on a belt than in a panel.

Move the composition into ItemIconCache, which now holds the visuals and
offers it in two forms: paintItem() for the world's fractional geometry
and getSquarePixmap() for widgets. WorldRenderer::drawWorldItem collapses
onto the former, and the recipe-selection dialog, the selection panel's
item chips and the recipe summary onto the latter. The inset fraction
that shapes the composition now has one definition instead of two.

An item with no icon file now shows its square alone rather than falling
back to text, so callers gate on the composed pixmap being null -- the
case of an item with neither square nor icon -- instead of on hasIcon().
The inline building_block icon beside a number keeps using the bare
getPixmap() and takes no square.

The chip and summary squares are drawn a few pixels larger than the icons
were, so the artwork keeps its size once the square insets it. Reloading
the visuals on a restart drops the composed pixmaps, which carry the
colors they were painted with (REQ-CFG-RELOAD).

REQ-UI-ITEM-ICON, REQ-UI-RECIPE-ICON, REQ-UI-SINGLE-SELECTION,
REQ-UI-RECIPE-SUMMARY, REQ-UI-HQ-PANEL

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-11 09:33:19 +02:00
parent c15f09855c
commit 9dd61b6e64
7 changed files with 189 additions and 73 deletions

View File

@@ -2,10 +2,26 @@
#include <QFile>
#include <QPainter>
#include <QRectF>
#include <QSvgRenderer>
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<std::string, ItemVisuals>::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<std::string, int> key(itemId, sizePx);
const std::pair<std::string, int> key(cacheKey, sizePx);
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
m_pixmapCache.find(key);
if (cached != m_pixmapCache.end())
@@ -49,8 +110,31 @@ QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx)
return cached->second;
}
const QByteArray& svg = getSvg(itemId);
QPixmap pixmap;
if (withSquare)
{
// 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);
@@ -60,5 +144,11 @@ QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx)
painter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&painter);
}
}
return m_pixmapCache.emplace(key, std::move(pixmap)).first->second;
}
void ItemIconCache::clearPixmapCache()
{
m_pixmapCache.clear();
}

View File

@@ -8,38 +8,76 @@
#include <QPixmap>
#include <QString>
// 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 "<item_id>.svg" icon files
// (typically "<configDir>/../icons/items").
explicit ItemIconCache(const QString& iconDir);
// iconDir is the directory holding the "<item_id>.svg" icon files (typically
// "<configDir>/../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<std::string, QByteArray> m_svgById;
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
};

View File

@@ -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<ItemIconCache>(
QDir::cleanPath(configDirPath + "/../icons/items"));
QDir::cleanPath(configDirPath + "/../icons/items"), &m_visuals);
m_buildingIcons = std::make_unique<BuildingIconCache>(
QDir::cleanPath(configDirPath + "/../icons/buildings"));
@@ -283,6 +283,9 @@ std::optional<GameConfig> 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)

View File

@@ -127,12 +127,17 @@ RecipeSelectionDialog::RecipeSelectionDialog(
const RecipeSelectionOption& option = options[static_cast<std::size_t>(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

View File

@@ -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<std::string, ItemVisuals>::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<double>(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,

View File

@@ -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<Entry>& 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<int>(index) / kChipsPerRow,

View File

@@ -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<Amount>& 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