mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-28 13:24:14 +02:00
6b577ee757
Shapes and images can now be resized, rotated, and skewed directly on the canvas, not just moved. Both share one small transform struct (rotation, then skew, then scale, anchored on a movable pivot) and one handle widget, so a corner drag, an edge skew, or grabbing the rotate handle behaves the same way and runs through the same matrix math everywhere, instead of every item type reinventing its own. Shapes also gained a proper pen tool (bezier paths, corner/smooth/ symmetric nodes), arc support, and mirroring. Images gained non-destructive cropping and colour-keyed transparency, both remember their own settings, so reopening the dialog picks up where you left off instead of starting over. Properties dialogs for both were extended to match (position, size, angle, skew), with undo/redo wired through for every handle drag. Old XML files can read easily as the transformation is only added if needed and the old syntax is still used and understood if it is not needed.
336 lines
12 KiB
C++
336 lines
12 KiB
C++
/*
|
|
Copyright 2006-2026 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "imagetransparentcolordialog.h"
|
|
|
|
#include <QDialogButtonBox>
|
|
#include <QGridLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QLabel>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPushButton>
|
|
#include <QSlider>
|
|
#include <QVBoxLayout>
|
|
#include <QVector>
|
|
|
|
namespace {
|
|
// Fits the dialog comfortably on a normal screen regardless of the
|
|
// source image's own resolution -- a photo straight off a phone
|
|
// would otherwise make this dialog enormous.
|
|
constexpr int MAX_DISPLAY_SIZE = 350;
|
|
}
|
|
|
|
/**
|
|
@brief ClickableImageLabel::ClickableImageLabel
|
|
@param sourceImage the full-resolution image to display and sample from
|
|
@param parent
|
|
*/
|
|
ClickableImageLabel::ClickableImageLabel(const QImage &sourceImage, QWidget *parent) :
|
|
QLabel(parent),
|
|
m_source(sourceImage)
|
|
{
|
|
const qreal scaleW = qreal(MAX_DISPLAY_SIZE) / m_source.width();
|
|
const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height();
|
|
m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one
|
|
|
|
const QImage displayImage = (m_displayScale < 1.0)
|
|
? m_source.scaled(m_source.size() * m_displayScale, Qt::KeepAspectRatio, Qt::SmoothTransformation)
|
|
: m_source;
|
|
setPixmap(QPixmap::fromImage(displayImage));
|
|
setCursor(Qt::CrossCursor);
|
|
setToolTip(tr("Cliquez pour choisir une couleur"));
|
|
}
|
|
|
|
/**
|
|
@brief ClickableImageLabel::mousePressEvent
|
|
Maps the click back to the original, full-resolution image before
|
|
sampling -- see the class-level comment for why.
|
|
@param event
|
|
*/
|
|
void ClickableImageLabel::mousePressEvent(QMouseEvent *event)
|
|
{
|
|
if (event->button() != Qt::LeftButton || pixmap().isNull())
|
|
return;
|
|
|
|
// The label may be larger than its pixmap (layout stretching); the
|
|
// pixmap itself is always drawn centered by Qt's default alignment,
|
|
// so the click has to be re-based against the pixmap's own rect
|
|
// within the label, not the label's own top-left.
|
|
const QRect pixmapRect(
|
|
(width() - pixmap().width()) / 2,
|
|
(height() - pixmap().height()) / 2,
|
|
pixmap().width(), pixmap().height());
|
|
if (!pixmapRect.contains(event->pos()))
|
|
return;
|
|
|
|
const QPoint withinPixmap = event->pos() - pixmapRect.topLeft();
|
|
QPoint originalPos(qRound(withinPixmap.x() / m_displayScale), qRound(withinPixmap.y() / m_displayScale));
|
|
originalPos.setX(qBound(0, originalPos.x(), m_source.width() - 1));
|
|
originalPos.setY(qBound(0, originalPos.y(), m_source.height() - 1));
|
|
|
|
emit colorPicked(m_source.pixelColor(originalPos));
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::ImageTransparentColorDialog
|
|
@param pixmap the image to pick a transparent colour from
|
|
@param parent
|
|
*/
|
|
ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap, const QList<QColor> &existingColors,
|
|
int existingTolerance, QWidget *parent) :
|
|
QDialog(parent),
|
|
m_sourceImage(basePixmap.toImage()),
|
|
m_pickedColors(existingColors),
|
|
m_tolerance(existingTolerance)
|
|
{
|
|
setWindowTitle(tr("Couleur transparente"));
|
|
|
|
m_sourceLabel = new ClickableImageLabel(m_sourceImage, this);
|
|
m_previewLabel = new QLabel(this);
|
|
|
|
m_hintLabel = new QLabel(this);
|
|
|
|
m_toleranceSlider = new QSlider(Qt::Horizontal, this);
|
|
m_toleranceSlider->setRange(0, 100);
|
|
m_toleranceSlider->setValue(m_tolerance);
|
|
|
|
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
|
m_okButton = buttons->button(QDialogButtonBox::Ok);
|
|
m_okButton->setEnabled(!m_pickedColors.isEmpty()); // nothing to apply until at least one colour has been picked
|
|
|
|
auto *grid = new QGridLayout;
|
|
grid->addWidget(new QLabel(tr("Image source")), 0, 0);
|
|
grid->addWidget(new QLabel(tr("Aperçu")), 0, 1);
|
|
grid->addWidget(m_sourceLabel, 1, 0);
|
|
grid->addWidget(m_previewLabel, 1, 1);
|
|
|
|
// An empty row to start with if existingColors is empty --
|
|
// rebuildSwatches() below populates it either way (including from
|
|
// existingColors on the first call), and again as colours get
|
|
// added or removed.
|
|
m_swatchesLayout = new QHBoxLayout;
|
|
|
|
auto *colorRow = new QHBoxLayout;
|
|
colorRow->addWidget(m_hintLabel);
|
|
colorRow->addStretch();
|
|
colorRow->addLayout(m_swatchesLayout);
|
|
|
|
auto *toleranceRow = new QHBoxLayout;
|
|
toleranceRow->addWidget(new QLabel(tr("Tolérance")));
|
|
toleranceRow->addWidget(m_toleranceSlider);
|
|
|
|
auto *mainLayout = new QVBoxLayout(this);
|
|
mainLayout->addLayout(grid);
|
|
mainLayout->addLayout(colorRow);
|
|
mainLayout->addLayout(toleranceRow);
|
|
mainLayout->addWidget(buttons);
|
|
|
|
connect(m_sourceLabel, &ClickableImageLabel::colorPicked, this, &ImageTransparentColorDialog::onColorPicked);
|
|
connect(m_toleranceSlider, &QSlider::valueChanged, this, &ImageTransparentColorDialog::onToleranceChanged);
|
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
|
|
|
rebuildSwatches(); // shows existingColors immediately, if any
|
|
updatePreview(); // and the preview already reflects them too, rather than only appearing after the next pick
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::onColorPicked
|
|
Adds to the set of picked colours rather than replacing the previous
|
|
pick -- clicking a second, different pixel used to silently discard
|
|
the first choice, with no way to work with more than one colour (a
|
|
white background *and* a grey border, say) in the same pass.
|
|
Skips an exact duplicate rather than adding a second, indistinguishable
|
|
swatch for it.
|
|
@param color the colour sampled from the source image
|
|
*/
|
|
void ImageTransparentColorDialog::onColorPicked(const QColor &color)
|
|
{
|
|
if (m_pickedColors.contains(color))
|
|
return;
|
|
|
|
m_pickedColors.append(color);
|
|
m_okButton->setEnabled(true);
|
|
rebuildSwatches();
|
|
updatePreview();
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::removeColor
|
|
Removes one colour from the set -- the counterpart onColorPicked()
|
|
was missing entirely before: picking the wrong pixel by mistake had
|
|
no way to undo except cancelling the whole dialog and starting over.
|
|
@param color the colour to remove
|
|
*/
|
|
void ImageTransparentColorDialog::removeColor(const QColor &color)
|
|
{
|
|
m_pickedColors.removeAll(color);
|
|
m_okButton->setEnabled(!m_pickedColors.isEmpty());
|
|
rebuildSwatches();
|
|
updatePreview();
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::rebuildSwatches
|
|
Rebuilds the row of picked-colour swatches from scratch against the
|
|
current m_pickedColors -- simpler and safer than trying to
|
|
incrementally add/remove individual widgets in sync with the list,
|
|
given the list only ever changes one colour at a time and is never
|
|
large enough for a full rebuild to be a real cost.
|
|
*/
|
|
void ImageTransparentColorDialog::rebuildSwatches()
|
|
{
|
|
QLayoutItem *item;
|
|
while ((item = m_swatchesLayout->takeAt(0)) != nullptr)
|
|
{
|
|
delete item->widget();
|
|
delete item;
|
|
}
|
|
|
|
for (const QColor &color : std::as_const(m_pickedColors))
|
|
{
|
|
auto *swatch = new QPushButton(this);
|
|
swatch->setFixedSize(24, 24);
|
|
swatch->setStyleSheet(QStringLiteral("background-color: rgb(%1,%2,%3); border: 1px solid palette(mid);")
|
|
.arg(color.red()).arg(color.green()).arg(color.blue()));
|
|
swatch->setToolTip(tr("rgb(%1, %2, %3) -- cliquer pour retirer").arg(color.red()).arg(color.green()).arg(color.blue()));
|
|
connect(swatch, &QPushButton::clicked, this, [this, color]() { removeColor(color); });
|
|
m_swatchesLayout->addWidget(swatch);
|
|
}
|
|
|
|
m_hintLabel->setText(m_pickedColors.isEmpty()
|
|
? tr("Cliquez sur l'image pour choisir une couleur")
|
|
: tr("Cliquez sur l'image pour ajouter une couleur, ou sur une pastille pour la retirer"));
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::onToleranceChanged
|
|
@param value the new tolerance, 0-100
|
|
*/
|
|
void ImageTransparentColorDialog::onToleranceChanged(int value)
|
|
{
|
|
m_tolerance = value;
|
|
if (!m_pickedColors.isEmpty())
|
|
updatePreview();
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::updatePreview
|
|
Recomputes the checkerboard-backed preview against the current set
|
|
of picked colours and the shared tolerance. Always runs against
|
|
m_sourceImage (the original, full-resolution image), not any
|
|
already-keyed result -- so adjusting the tolerance, or adding or
|
|
removing a colour, re-evaluates every picked colour from scratch
|
|
each time rather than compounding successive passes.
|
|
*/
|
|
void ImageTransparentColorDialog::updatePreview()
|
|
{
|
|
const QImage keyed = applyColorKey(m_sourceImage, m_pickedColors, m_tolerance);
|
|
m_previewLabel->setPixmap(onCheckerboard(keyed));
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::resultPixmap
|
|
@return the colour-keyed pixmap against every picked colour, or the
|
|
original pixmap unchanged if none were ever picked (the Ok button
|
|
stays disabled until at least one is, so this is mostly a defensive
|
|
fallback).
|
|
*/
|
|
QPixmap ImageTransparentColorDialog::resultPixmap() const
|
|
{
|
|
if (m_pickedColors.isEmpty())
|
|
return QPixmap::fromImage(m_sourceImage);
|
|
return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors, m_tolerance));
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::applyColorKey
|
|
Binary transparency within tolerance, not a smooth falloff: every
|
|
pixel within `tolerance` (0-100, mapped onto the maximum possible
|
|
RGB distance) of *any* of keyColors becomes fully transparent,
|
|
everything else keeps its existing alpha untouched. Squared distance
|
|
throughout, avoiding a sqrt per pixel; breaks out of the inner loop
|
|
on the first matching colour, since further matches wouldn't change
|
|
the outcome.
|
|
@param source the image to key
|
|
@param keyColors the colours to make transparent
|
|
@param tolerance 0 (exact match only) to 100 (everything)
|
|
@return the resulting image, always in Format_ARGB32
|
|
*/
|
|
QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList<QColor> &keyColors, int tolerance)
|
|
{
|
|
QImage result = source.convertToFormat(QImage::Format_ARGB32);
|
|
if (keyColors.isEmpty())
|
|
return result;
|
|
|
|
QVector<QRgb> keys;
|
|
keys.reserve(keyColors.size());
|
|
for (const QColor &c : keyColors)
|
|
keys.append(c.rgb());
|
|
|
|
const qint64 threshold = qint64(tolerance) * tolerance * 3 * 255 * 255 / (100 * 100);
|
|
|
|
for (int y = 0; y < result.height(); ++y)
|
|
{
|
|
QRgb *line = reinterpret_cast<QRgb *>(result.scanLine(y));
|
|
for (int x = 0; x < result.width(); ++x)
|
|
{
|
|
const QRgb px = line[x];
|
|
for (const QRgb &key : keys)
|
|
{
|
|
const int dr = qRed(px) - qRed(key), dg = qGreen(px) - qGreen(key), db = qBlue(px) - qBlue(key);
|
|
const qint64 distSq = qint64(dr) * dr + qint64(dg) * dg + qint64(db) * db;
|
|
if (distSq <= threshold)
|
|
{
|
|
line[x] = qRgba(qRed(px), qGreen(px), qBlue(px), 0);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
@brief ImageTransparentColorDialog::onCheckerboard
|
|
Composites `image` over a light/dark checkerboard, the standard
|
|
visual convention for showing where an image is transparent --
|
|
without this, a fully keyed-out region would just show whatever
|
|
widget background happens to be behind it, easy to misread as "still
|
|
opaque, just white" rather than "correctly transparent".
|
|
@param image the (possibly partially transparent) image to composite
|
|
@return a checkerboard-backed pixmap ready to display
|
|
*/
|
|
QPixmap ImageTransparentColorDialog::onCheckerboard(const QImage &image)
|
|
{
|
|
const int cell = 8;
|
|
QPixmap board(image.size());
|
|
QPainter painter(&board);
|
|
for (int y = 0; y < image.height(); y += cell)
|
|
{
|
|
for (int x = 0; x < image.width(); x += cell)
|
|
{
|
|
const bool dark = ((x / cell) + (y / cell)) % 2 == 0;
|
|
painter.fillRect(x, y, cell, cell, dark ? QColor(200, 200, 200) : QColor(255, 255, 255));
|
|
}
|
|
}
|
|
painter.drawImage(0, 0, image);
|
|
painter.end();
|
|
return board;
|
|
}
|