wiRedPanda
Logic Circuit Simulator
Loading...
Searching...
No Matches
ElementAppearance.cpp
Go to the documentation of this file.
1// Copyright 2015 - 2026, GIBIS-UNIFESP and the wiRedPanda contributors
2// SPDX-License-Identifier: GPL-3.0-or-later
3
5
6#include <algorithm>
7#include <cmath>
8#include <memory>
9
10#include <QDateTime>
11#include <QFile>
12#include <QFileInfo>
13#include <QHash>
14#include <QPainter>
15#include <QPen>
16#include <QPixmapCache>
17#include <QRegularExpression>
18#include <QSvgRenderer>
19
20#include "App/Core/Common.h"
23
24namespace {
25
33QByteArray orientSvgTextNodes(const QByteArray &svgBytes, const qreal angle, const bool flipX, const bool flipY)
34{
35 static const QRegularExpression textTag(QStringLiteral("<text\\b[^>]*>"));
36 static const QRegularExpression idAttr(QStringLiteral("\\bid\\s*=\\s*\"([^\"]*)\""));
37 static const QRegularExpression transformAttr(QStringLiteral("\\btransform\\s*="));
38
39 QString svg = QString::fromUtf8(svgBytes);
40
41 // Pass 1: ensure every <text> opening tag carries an id so boundsOnElement() can address it.
42 // Inkscape already ids these, but be defensive for hand-written assets.
43 {
44 QString out;
45 qsizetype last = 0;
46 int generated = 0;
47 auto matches = textTag.globalMatch(svg);
48 while (matches.hasNext()) {
49 const auto match = matches.next();
50 out += svg.mid(last, match.capturedStart() - last);
51 QString tag = match.captured(0);
52 if (!idAttr.match(tag).hasMatch()) {
53 // Insert after "<text" (5 chars) so the new attribute precedes the existing ones.
54 tag.insert(5, QStringLiteral(" id=\"wpflip-text-%1\"").arg(generated++));
55 }
56 out += tag;
57 last = match.capturedEnd();
58 }
59 out += svg.mid(last);
60 svg = out;
61 }
62
63 QSvgRenderer probe(svg.toUtf8());
64 if (!probe.isValid()) {
65 return svgBytes;
66 }
67
68 const qreal sx = flipX ? -1.0 : 1.0;
69 const qreal sy = flipY ? -1.0 : 1.0;
70
71 // Pass 2: inject a counter-orientation transform on each <text>, pivoting about that text's
72 // own centre. The item applies Flip ∘ Rotate(angle) to the pixmap content, so the inverse the
73 // glyph must carry is Rotate(-angle) ∘ Flip (rotate outer, scale inner). The rotate term is
74 // omitted at angle 0 and the scale term when unflipped, so the flip-only output is unchanged.
75 QString out;
76 qsizetype last = 0;
77 auto matches = textTag.globalMatch(svg);
78 while (matches.hasNext()) {
79 const auto match = matches.next();
80 out += svg.mid(last, match.capturedStart() - last);
81 QString tag = match.captured(0);
82
83 const auto idMatch = idAttr.match(tag);
84 if (idMatch.hasMatch() && !transformAttr.match(tag).hasMatch()) {
85 const QRectF bounds = probe.boundsOnElement(idMatch.captured(1));
86 if (!bounds.isEmpty()) {
87 const QPointF c = bounds.center();
88 QString ops;
89 if (angle != 0.0) {
90 ops += QStringLiteral("rotate(%1) ").arg(-angle);
91 }
92 if (flipX || flipY) {
93 ops += QStringLiteral("scale(%1,%2) ").arg(sx).arg(sy);
94 }
95 const QString transform =
96 QStringLiteral(" transform=\"translate(%1,%2) %3translate(%4,%5)\"")
97 .arg(c.x()).arg(c.y()).arg(ops).arg(-c.x()).arg(-c.y());
98 tag.insert(5, transform);
99 }
100 }
101
102 out += tag;
103 last = match.capturedEnd();
104 }
105 out += svg.mid(last);
106
107 return out.toUtf8();
108}
109
115QPixmap orientedSvgPixmap(const QString &resolvedPath, const qreal angle, const bool flipX, const bool flipY)
116{
117 // Canonicalize to [0,360) so equivalent rotations (e.g. -90 and 270, which render identically)
118 // share one cache entry instead of growing the key space without bound — MCP can request any
119 // angle and rotate-left produces negatives.
120 const qreal canonAngle = std::fmod(std::fmod(angle, 360.0) + 360.0, 360.0);
121 // Namespaced so this can never collide with Qt's own internal QPixmapCache keys (which are
122 // always prefixed "qt_pixmap") or anything else the app might insert into the same shared,
123 // process-wide cache.
124 const QString key = QLatin1String("wp_oriented_svg:") + resolvedPath + QLatin1Char('|')
125 + QString::number(canonAngle) + QLatin1Char('|')
126 + (flipX ? QLatin1Char('1') : QLatin1Char('0'))
127 + (flipY ? QLatin1Char('1') : QLatin1Char('0'));
128
129 QPixmap cached;
130 if (QPixmapCache::find(key, &cached)) {
131 return cached;
132 }
133
134 QFile file(resolvedPath);
135 if (!file.open(QIODevice::ReadOnly)) {
136 return {};
137 }
138 const QByteArray raw = file.readAll();
139 // No baked-in <text> → nothing to correct; return null so the caller keeps the plain
140 // item-flipped base pixmap (identical to legacy behaviour, no redundant re-render).
141 if (!raw.contains("<text")) {
142 return {};
143 }
144 const QByteArray modified = orientSvgTextNodes(raw, canonAngle, flipX, flipY);
145
146 QSvgRenderer renderer(modified);
147 if (!renderer.isValid()) {
148 return {};
149 }
150
151 QPixmap pixmap(renderer.defaultSize());
152 pixmap.fill(Qt::transparent);
153 QPainter painter(&pixmap);
154 painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
155 renderer.render(&painter);
156 painter.end();
157
158 QPixmapCache::insert(key, pixmap);
159 return pixmap;
160}
161
172std::shared_ptr<QSvgRenderer> sharedSvgRenderer(const QString &resolvedPath, const qreal angle, const bool flipX, const bool flipY)
173{
174 static QHash<QString, std::shared_ptr<QSvgRenderer>> cache;
175
176 const qreal canonAngle = std::fmod(std::fmod(angle, 360.0) + 360.0, 360.0);
177 // mtime keeps this in step with QPixmap::load()'s own QPixmapCache keying (see setPixmap()
178 // below) so a user-edited custom SVG appearance still picks up on next load.
179 const QDateTime mtime = QFileInfo(resolvedPath).lastModified();
180 const QString key = resolvedPath + QLatin1Char('|')
181 + QString::number(mtime.toMSecsSinceEpoch()) + QLatin1Char('|')
182 + QString::number(canonAngle) + QLatin1Char('|')
183 + (flipX ? QLatin1Char('1') : QLatin1Char('0'))
184 + (flipY ? QLatin1Char('1') : QLatin1Char('0'));
185
186 if (const auto it = cache.constFind(key); it != cache.constEnd()) {
187 return it.value();
188 }
189
190 QFile file(resolvedPath);
191 if (!file.open(QIODevice::ReadOnly)) {
192 cache.insert(key, nullptr);
193 return nullptr;
194 }
195 QByteArray svg = file.readAll();
196
197 const bool oriented = angle != 0.0 || flipX || flipY;
198 if (oriented && svg.contains("<text")) {
199 svg = orientSvgTextNodes(svg, canonAngle, flipX, flipY);
200 }
201
202 auto renderer = std::make_shared<QSvgRenderer>(svg);
203 if (!renderer->isValid()) {
204 renderer.reset();
205 }
206 cache.insert(key, renderer);
207 return renderer;
208}
209
210} // namespace
211
213 : m_owner(owner)
214{
215}
216
218
220 const QStringList &alternativeAppearances,
221 const QString &pixmapPath)
222{
223 m_defaultAppearances = defaultAppearances;
224 m_pixmapPath = pixmapPath;
225
226 // For elements whose pixmapPath is theme-dependent (e.g. memory elements),
227 // defaultAppearances may be left empty in the metadata to avoid evaluating the
228 // theme path during static initialisation (before QApplication exists).
229 // Populate it lazily here from the now-correct pixmapPath.
230 if (m_defaultAppearances.isEmpty() && !m_pixmapPath.isEmpty()) {
231 m_defaultAppearances = QStringList({m_pixmapPath});
232 }
233
234 m_alternativeAppearances = alternativeAppearances.isEmpty() ? m_defaultAppearances : alternativeAppearances;
235}
236
238{
239 // A procedural render pixmap (IC/Mux/Demux/TruthTable) isn't anchored at local (0,0) once
240 // the element's ports outgrow the nominal 64x64 body — boundingRect() (not the raw pixmap
241 // rect) is the actual footprint, and its own top-left tracks that offset.
242 if (m_hasCustomRenderPixmap) {
243 return m_owner->boundingRect().center();
244 }
245 return QRectF(m_pixmap.rect()).center();
246}
247
248void ElementAppearance::setPixmap(const int index)
249{
250 setPixmap(m_usingDefaultAppearance ? m_defaultAppearances.at(index) : m_alternativeAppearances.at(index));
251}
252
253void ElementAppearance::setPixmap(const QString &pixmapPath)
254{
255 // Skip if unchanged to avoid redundant loads and cache invalidation
256 if (pixmapPath.isEmpty() || (pixmapPath == m_currentPixmapPath)) {
257 return;
258 }
259
260 // Remember the displayed size so a growth (small SVG → large custom raster) can trigger a
261 // full device-cache reset below; captured before applyOrientation() replaces m_pixmap.
262 const QSize previousSize = m_pixmap.size();
263
264 // The pixmap about to load may have different dimensions than the current one,
265 // which changes what boundingRect() reports (it's derived from pixmap().rect()) —
266 // Qt requires prepareGeometryChange() before any such geometry-affecting mutation,
267 // or the old footprint's stale pixels are never scheduled for repaint.
268 m_owner->prepareGeometryChange();
269
270 // Resolution against contextDir already happened once, at load time (see
271 // ExternalFilePath::forReading(), called from GraphicElementSerializer) — by the
272 // time a path reaches here it's either a resource reference or already directly
273 // usable (a fresh absolute path from a file picker, or an already-resolved one
274 // read back from a saved project). Nothing left to do here but load it.
275 const QString path = pixmapPath;
276
277 // QPixmap::load() already caches internally via the shared QPixmapCache, keyed on
278 // path + mtime + size (see MainWindow's setCacheLimit() call for the app-wide budget) — a
279 // wrapper cache of our own here would be both redundant and, keyed on the bare path alone,
280 // staler than what load() already gives us for free.
281 if (!m_basePixmap.load(path)) {
282 const QFileInfo info(path);
283 const QString reason = !info.exists()
284 ? tr("File does not exist")
285 : !info.isReadable()
286 ? tr("File is not readable")
287 : tr("Unknown reason");
288
289 // Load the default appearance so the element remains renderable before the exception unwinds
290 m_basePixmap.load(m_defaultAppearances.constFirst());
291 m_pixmap = m_basePixmap;
292 // Remember this exact request failed so a later call with the same broken path (e.g.
293 // every simulation tick's refresh()) is skipped by the guard above instead of
294 // re-attempting the load and re-throwing — each distinct failure surfaces once.
295 m_currentPixmapPath = pixmapPath;
296 qCDebug(zero) << "Problem loading pixmapPath: " << path;
297 throw PANDACEPTION("Couldn't load pixmap: %1 (%2)", path, reason);
298 }
299
300 m_resolvedPixmapPath = path;
301 // Loading a base pixmap hands display ownership back to the derived-pixmap flow.
302 m_hasCustomRenderPixmap = false;
303 // Derive the displayed pixmap from the base, swapping in a text-corrected variant when the
304 // element is rotated or flipped.
306
307 // A size change grows/shrinks boundingRect(); DeviceCoordinateCache keeps the old device
308 // tile on a plain update(), so reset the cache to force a full re-render and avoid a stale
309 // ghost of the previous appearance.
310 if (m_pixmap.size() != previousSize) {
311 m_owner->invalidateRenderCache();
312 }
313
314 // The transform origin must be updated whenever the pixmap changes so that
315 // rotation and scale operations remain centred on the new image
316 m_owner->setTransformOriginPoint(pixmapCenter());
317 m_owner->update();
318
319 m_currentPixmapPath = pixmapPath;
320}
321
322QPixmap ElementAppearance::previewPixmapAt(const int index, const QSize &size) const
323{
324 if (index < 0 || index >= m_alternativeAppearances.size()) {
325 return {};
326 }
327
328 // m_alternativeAppearances is always seeded to match m_defaultAppearances and only diverges
329 // per-slot via setAlternativeAppearanceAt(), so it's already "the currently active path for
330 // that slot" — no need to branch on the (element-wide) m_usingDefaultAppearance flag here.
331 QPixmap pixmap;
332 pixmap.load(m_alternativeAppearances.at(index));
333
334 return pixmap.isNull() ? pixmap : pixmap.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
335}
336
338{
339 // See the identical call in setPixmap(): this pixmap's dimensions feed boundingRect().
340 m_owner->prepareGeometryChange();
341 const QSize previousSize = m_pixmap.size();
342 m_pixmap = pixmap;
343 m_hasCustomRenderPixmap = true;
344 // A size change (e.g. a port count change resizing the procedural body) leaves a stale
345 // DeviceCoordinateCache tile; reset the cache so the next paint re-renders in full.
346 if (m_pixmap.size() != previousSize) {
347 m_owner->invalidateRenderCache();
348 }
349 // The owner's body is drawn from this footprint, so rotation must pivot on its centre —
350 // the base-pixmap flow gets the same guarantee from setPixmap().
351 m_owner->setTransformOriginPoint(pixmapCenter());
352}
353
355{
356 // A procedural render pixmap (IC, TruthTable, Mux, Demux) is the owner's body-sizing/body
357 // canvas and is not derived from m_basePixmap — re-deriving here would replace it with the
358 // stale (or null) base skin. Orientation is handled by the item transform alone.
359 if (m_hasCustomRenderPixmap) {
360 m_owner->update();
361 return;
362 }
363
364 // Only rotatable elements transform their graphic, so only they need the baked <text> labels
365 // counter-oriented. A non-rotatable element keeps its icon upright (it moves only its ports),
366 // so its text — if any — must render as authored and never be counter-oriented.
367 const bool oriented = m_owner->rotatesGraphic()
368 && (m_owner->isFlippedX() || m_owner->isFlippedY() || (m_owner->rotation() != 0.0));
369 // A rotated/flipped SVG variant can differ in size from the base skin, which changes what
370 // boundingRect() reports (it's derived from pixmap().rect()). setPixmap()/setRenderPixmap()
371 // already guard their own callers, but reapplyAppearanceOrientation() reaches here directly
372 // on a rotate/flip, so the mutation below needs its own prepareGeometryChange().
373 m_owner->prepareGeometryChange();
374 if (oriented && m_resolvedPixmapPath.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) {
375 const QPixmap variant = orientedSvgPixmap(m_resolvedPixmapPath, m_owner->rotation(), m_owner->isFlippedX(), m_owner->isFlippedY());
376 m_pixmap = variant.isNull() ? m_basePixmap : variant;
377 } else {
378 m_pixmap = m_basePixmap;
379 }
380 rebuildSvgRenderer();
381 m_owner->update();
382}
383
384void ElementAppearance::rebuildSvgRenderer()
385{
386 // render() draws this vector renderer so SVG elements stay crisp at any zoom; raster (PNG/JPG)
387 // appearances leave it null and fall back to drawing the rasterised m_pixmap.
388 if (!m_resolvedPixmapPath.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) {
389 m_svgRenderer.reset();
390 return;
391 }
392
393 // Counter-orient each <text> while rotated/flipped so pin labels stay upright — the same
394 // correction orientedSvgPixmap() bakes into the rasterised variant. Gated on rotatesGraphic()
395 // because a non-rotatable element never transforms its graphic, so its text stays as authored.
396 // Zeroing angle/flip when not oriented also means every unoriented element (the vast majority)
397 // shares one sharedSvgRenderer() cache entry regardless of its nominal rotation/flip state.
398 const bool oriented = m_owner->rotatesGraphic()
399 && (m_owner->isFlippedX() || m_owner->isFlippedY() || (m_owner->rotation() != 0.0));
400
401 m_svgRenderer = sharedSvgRenderer(m_resolvedPixmapPath,
402 oriented ? m_owner->rotation() : 0.0,
403 oriented && m_owner->isFlippedX(),
404 oriented && m_owner->isFlippedY());
405}
406
407void ElementAppearance::setAppearance(const bool defaultAppearance, const QString &fileName)
408{
409 if (defaultAppearance) {
410 m_alternativeAppearances = m_defaultAppearances;
411 } else {
412 m_alternativeAppearances[0] = fileName;
413 }
414
415 m_usingDefaultAppearance = defaultAppearance;
416 setPixmap(0);
417}
418
419void ElementAppearance::setAppearanceAt(const int index, const QString &fileName)
420{
421 if (index < 0 || index >= m_alternativeAppearances.size()) {
422 return;
423 }
424
425 if (fileName.isEmpty()) {
426 m_alternativeAppearances[index] = m_defaultAppearances.at(index);
427 } else {
428 m_alternativeAppearances[index] = fileName;
429 }
430
431 m_usingDefaultAppearance = (m_alternativeAppearances == m_defaultAppearances);
432 setPixmap(index);
433}
434
435void ElementAppearance::setAlternativeAppearanceAt(const int index, const QString &path)
436{
437 if (index < 0 || index >= m_alternativeAppearances.size()) {
438 return;
439 }
440 m_alternativeAppearances[index] = path;
441}
442
444{
445 m_usingDefaultAppearance = std::equal(
446 m_defaultAppearances.begin(), m_defaultAppearances.end(),
447 m_alternativeAppearances.begin(), m_alternativeAppearances.end()
448 );
449}
450
452{
453 QStringList result;
454 for (int i = 0; i < m_alternativeAppearances.size() && i < m_defaultAppearances.size(); ++i) {
455 const QString &appearance = m_alternativeAppearances.at(i);
456 if (appearance != m_defaultAppearances.at(i) && !appearance.startsWith(":/")) {
457 result.append(appearance);
458 }
459 }
460 return result;
461}
462
463void ElementAppearance::render(QPainter *painter, const QRectF &boundingRect, const bool selected) const
464{
465 if (selected) {
466 painter->save();
467 painter->setBrush(m_selectionBrush);
468 // 0.5 pen width keeps the selection outline thin regardless of zoom level
469 painter->setPen(QPen(m_selectionPen, 0.5, Qt::SolidLine));
470 // Corner radius of 5 matches the visual rounding used on element bodies
471 painter->drawRoundedRect(boundingRect, 5, 5);
472 painter->restore();
473 }
474
475 // Draw the body from vector data (crisp at any zoom) when the appearance is an SVG; fall back to
476 // the rasterised pixmap for raster (PNG/JPG) appearances. DeviceCoordinateCache re-renders this
477 // per zoom level, so the SVG stays sharp instead of scaling a fixed-size bitmap.
478 if (m_svgRenderer && m_svgRenderer->isValid()) {
479 painter->save();
480 painter->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing |
481 QPainter::SmoothPixmapTransform, true);
482 // Render into the SVG's native box at the origin — the same 0,0..defaultSize the raster occupied.
483 m_svgRenderer->render(painter, QRectF(QPointF(0, 0), QSizeF(m_svgRenderer->defaultSize())));
484 painter->restore();
485 } else {
486 // Pixmap origin is always (0,0) in item coordinates; the transform origin
487 // (centre of the pixmap) is set separately via setTransformOriginPoint().
488 painter->drawPixmap(QPoint(0, 0), m_pixmap);
489 }
490}
491
493{
494 m_selectionBrush = theme.m_selectionBrush;
495 m_selectionPen = theme.m_selectionPen;
496}
Common logging utilities, the Pandaception error type, and helper macros.
#define PANDACEPTION(msg,...)
Definition Common.h:98
#define qCDebug(category)
Definition Common.h:29
Owns a GraphicElement's pixmap / SVG appearance, appearance list, and selection paint.
Abstract base class for all graphical circuit elements.
Theme management types and singleton ThemeManager.
QStringList externalFiles() const
Returns the external (non-resource) appearance file paths the element depends on.
ElementAppearance(GraphicElement *owner)
Constructs the appearance bound to its owning owner element.
void recomputeUsingDefault()
Recomputes the using-default flag by comparing the alternative and default lists.
void setRenderPixmap(const QPixmap &pixmap)
QPointF pixmapCenter() const
Returns the centre point of the displayed pixmap in local coordinates.
~ElementAppearance()
Out-of-line so the unique_ptr to the forward-declared QSvgRenderer can be destroyed.
const QStringList & alternativeAppearances() const
Returns the active (possibly user-customised) appearance paths.
void applyOrientation()
Re-derives the displayed pixmap (and SVG renderer) from the owner's current orientation.
void render(QPainter *painter, const QRectF &boundingRect, const bool selected) const
Draws the selection highlight (when selected) and the SVG/pixmap body onto painter.
void setAppearance(const bool defaultAppearance, const QString &fileName)
Switches the appearance: restores defaults or applies fileName at slot 0.
const QStringList & defaultAppearances() const
Returns the built-in default appearance paths.
void setAlternativeAppearanceAt(const int index, const QString &path)
Sets one alternative-appearance slot without reloading the pixmap (serializer / subclass use).
void setPixmap(const int index)
Loads and applies the appearance at position index in the active list.
void seedFromMetadata(const QStringList &defaultAppearances, const QStringList &alternativeAppearances, const QString &pixmapPath)
QPixmap previewPixmapAt(const int index, const QSize &size) const
void applyTheme(const ThemeAttributes &theme)
Applies the selection-highlight colors from theme.
QPixmap pixmap() const
Returns the pixmap currently displayed.
void setAppearanceAt(const int index, const QString &fileName)
Sets a custom appearance at index (empty fileName restores that slot's default).
Abstract base class for all graphical circuit elements in wiRedPanda.
Contains all color attributes for a theme.
QColor m_selectionBrush
QColor m_selectionPen