Merge pull request #1378 from ispyisail/feature/symbol-scale

Add Scale element to the element editor, keeping terminals on the grid
This commit is contained in:
ispyisail
2026-10-08 08:23:04 +13:00
committed by GitHub
10 changed files with 502 additions and 1 deletions
+2
View File
@@ -470,6 +470,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/editor/ui/polygoneditor.h
${QET_DIR}/sources/editor/ui/rectangleeditor.cpp
${QET_DIR}/sources/editor/ui/rectangleeditor.h
${QET_DIR}/sources/editor/ui/scaleelementdialog.cpp
${QET_DIR}/sources/editor/ui/scaleelementdialog.h
${QET_DIR}/sources/editor/ui/terminaleditor.cpp
${QET_DIR}/sources/editor/ui/terminaleditor.h
${QET_DIR}/sources/editor/ui/texteditor.cpp
+95
View File
@@ -17,6 +17,7 @@
*/
#include "editorcommands.h"
#include "../diagram.h"
#include "symbolscale.h"
/**
@brief ElementEditionCommand::ElementEditionCommand
@@ -785,3 +786,97 @@ void FlipElementsCommand::undo()
{
redo();
}
/**
@brief ScaleElementCommand::ScaleElementCommand
@param scene : the element to scale
@param factor : scale factor, applied about the hotspot
@param scale_text : also scale font sizes
@param parent : parent undo command
*/
ScaleElementCommand::ScaleElementCommand(ElementScene *scene,
qreal factor,
bool scale_text,
QUndoCommand *parent) :
ElementEditionCommand(QObject::tr("mise à l'échelle de l'élément", "undo caption"),
scene, nullptr, parent),
m_factor(factor)
{
const auto scaledFont = [factor, scale_text](QFont font) {
if (scale_text) {
if (font.pointSizeF() > 0) {
font.setPointSize(SymbolScale::scaledFontSize(font.pointSizeF(), factor));
} else if (font.pixelSize() > 0) {
font.setPixelSize(SymbolScale::scaledFontSize(font.pixelSize(), factor));
}
}
return font;
};
const auto parts = scene->primitives();
for (CustomElementPart *part : parts)
{
QGraphicsItem *item = part->toItem();
//Font before position: changing the font can move a text
//to keep its alignment.
if (auto text = qgraphicsitem_cast<PartText *>(item)) {
addChange(text, "font", scaledFont(text->font()));
addChange(text, "pos", text->pos() * factor);
}
else if (auto field = qgraphicsitem_cast<PartDynamicTextField *>(item)) {
addChange(field, "font", scaledFont(field->font()));
if (field->textWidth() > 0) {
addChange(field, "textWidth", field->textWidth() * factor);
}
addChange(field, "pos", field->pos() * factor);
}
else if (auto terminal = qgraphicsitem_cast<PartTerminal *>(item)) {
addChange(terminal, "label_font", scaledFont(terminal->labelFont()));
addChange(terminal, "label_pos", terminal->labelPos() * factor);
addChange(terminal, "pos", terminal->pos() * factor);
}
else {
m_geometry_parts << part;
if (auto line = qgraphicsitem_cast<PartLine *>(item)) {
addChange(line, "length1", line->firstEndLength() * factor);
addChange(line, "length2", line->secondEndLength() * factor);
}
}
}
}
void ScaleElementCommand::addChange(QObject *object, const char *name, const QVariant &after)
{
m_changes << PropertyChange{object, name, object->property(name), after};
}
/**
@brief ScaleElementCommand::scaleGeometry
Scale the drawn parts about the hotspot, from \a from times their
original size to \a to times it.
*/
void ScaleElementCommand::scaleGeometry(qreal from, qreal to)
{
const QRectF before(0, 0, from, from);
const QRectF after(0, 0, to, to);
for (CustomElementPart *part : std::as_const(m_geometry_parts)) {
part->startUserTransformation(before);
part->handleUserTransformation(before, after);
}
}
void ScaleElementCommand::undo()
{
scaleGeometry(m_factor, 1);
for (const PropertyChange &change : std::as_const(m_changes)) {
change.object->setProperty(change.name.constData(), change.before);
}
}
void ScaleElementCommand::redo()
{
scaleGeometry(1, m_factor);
for (const PropertyChange &change : std::as_const(m_changes)) {
change.object->setProperty(change.name.constData(), change.after);
}
}
+33
View File
@@ -315,4 +315,37 @@ private:
qreal m_axis_y = 0;
};
/**
@brief The ScaleElementCommand class
Scales the whole element about its hotspot by a fixed factor, as one
undo step. Drawn parts are scaled the way the resize handles scale them;
texts, dynamic text fields and terminals are set explicitly, so their
font sizes, terminal name offsets and line end sizes follow too, and a
terminal lands exactly on its new position.
*/
class ScaleElementCommand : public ElementEditionCommand
{
public:
ScaleElementCommand(ElementScene *scene, qreal factor,
bool scale_text, QUndoCommand *parent = nullptr);
void undo() override;
void redo() override;
private:
struct PropertyChange {
QObject *object;
QByteArray name;
QVariant before;
QVariant after;
};
void addChange(QObject *object, const char *name, const QVariant &after);
void scaleGeometry(qreal from, qreal to);
qreal m_factor;
/// Parts scaled through handleUserTransformation()
QList<CustomElementPart *> m_geometry_parts;
/// Values set directly, in the order they are applied
QList<PropertyChange> m_changes;
};
#endif
+107
View File
@@ -0,0 +1,107 @@
/*
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/>.
*/
#ifndef SYMBOLSCALE_H
#define SYMBOLSCALE_H
#include <QList>
#include <QPointF>
#include <QtMath>
/**
Which factors an element can be scaled by, in the element editor, without
taking its terminals off the grid of the folio.
A terminal's position is its wire end, relative to the hotspot, and the
hotspot of a placed element sits on a grid point. So the wires of an
element line up with the grid when every terminal coordinate is a
multiple of the grid size. The element is scaled about its hotspot, so a
factor is safe when every coordinate times the factor is still a
multiple of the grid size.
The same rule covers an element whose terminals are off the grid today:
the factors offered are the ones that bring them all on to it.
*/
namespace SymbolScale
{
/// The folio grid the collection is drawn to. Fixed rather than read
/// from the user's settings: elements are shared between users.
constexpr int grid = 10;
/// The factors offered, in the order shown.
inline QList<qreal> candidates()
{
return {0.5, 1.5, 2.0, 2.5, 3.0, 4.0};
}
/// True if \a value is a multiple of \a grid_size, ignoring the
/// rounding left by saving with two decimals.
inline bool onGrid(qreal value, int grid_size = grid)
{
const qreal steps = value / grid_size;
return qAbs(steps - qRound(steps)) * grid_size < 0.005;
}
/// True if every terminal in \a terminals is on the grid.
inline bool allOnGrid(const QList<QPointF> &terminals, int grid_size = grid)
{
for (const QPointF &p : terminals) {
if (!onGrid(p.x(), grid_size) || !onGrid(p.y(), grid_size)) {
return false;
}
}
return true;
}
/// How many of \a terminals are off the grid.
inline int offGridCount(const QList<QPointF> &terminals, int grid_size = grid)
{
int count = 0;
for (const QPointF &p : terminals) {
if (!onGrid(p.x(), grid_size) || !onGrid(p.y(), grid_size)) {
++count;
}
}
return count;
}
/// The candidate factors that leave every terminal of \a terminals on
/// the grid. An element with no terminal can take any of them.
inline QList<qreal> safeFactors(const QList<QPointF> &terminals, int grid_size = grid)
{
QList<qreal> safe;
for (const qreal factor : candidates()) {
QList<QPointF> scaled;
for (const QPointF &p : terminals) {
scaled << p * factor;
}
if (allOnGrid(scaled, grid_size)) {
safe << factor;
}
}
return safe;
}
/// Font size after scaling by \a factor: whole points, and never
/// below 4 pt, so a halved element keeps readable text.
inline int scaledFontSize(qreal size, qreal factor)
{
return qMax(4, qRound(size * factor));
}
}
#endif // SYMBOLSCALE_H
+31 -1
View File
@@ -54,6 +54,7 @@
#include "../../dxf/dxftoelmt.h"
#include "../../qet_elementscaler/qet_elementscaler.h"
#include "../UndoCommand/openelmtcommand.h"
#include "scaleelementdialog.h"
#include "../../toolbarsettings.h"
#include <QSettings>
@@ -1154,6 +1155,12 @@ void QETElementEditor::setupActions()
ShortcutManager::instance().registerAction(ui->m_mirror_action, "elementeditor.mirror", tr("Éditeur d'élément"), Qt::Key_M);
connect(ui->m_mirror_action, &QAction::triggered, [this]() {this -> elementScene() -> undoStack().push(new MirrorElementsCommand(this->elementScene()));});
//Scale the whole element by a factor that keeps its terminals on the grid
m_scale_element_action = new QAction(tr("Mettre l'élément à l'échelle..."), this);
ui->m_edit_menu->addAction(m_scale_element_action);
ShortcutManager::instance().registerAction(m_scale_element_action, "elementeditor.scale_element", tr("Éditeur d'élément"), QKeySequence());
connect(m_scale_element_action, &QAction::triggered, this, &QETElementEditor::scaleElement);
//Zoom action
ShortcutManager::instance().registerAction(ui->m_zoom_in_action, "elementeditor.zoom_in", tr("Éditeur d'élément"), QKeySequence::ZoomIn);
@@ -1247,7 +1254,8 @@ void QETElementEditor::updateAction()
//Action disabled if read only
auto ro_list = m_add_part_action_grp->actions();
ro_list << ui->m_paste_from_file_action
<< ui->m_paste_from_element_action;
<< ui->m_paste_from_element_action
<< m_scale_element_action;
for (auto action : std::as_const(ro_list)) {
action->setDisabled(m_read_only);
}
@@ -1803,6 +1811,28 @@ void QETElementEditor::on_m_import_dxf_triggered()
}
}
/**
@brief QETElementEditor::scaleElement
Ask for a factor that keeps the terminals on the grid,
then scale the whole element by it.
*/
void QETElementEditor::scaleElement()
{
QList<QPointF> terminals;
for (CustomElementPart *part : m_elmt_scene->primitives()) {
if (auto terminal = qgraphicsitem_cast<PartTerminal *>(part->toItem())) {
terminals << terminal->scenePos();
}
}
ScaleElementDialog dialog(terminals, this);
if (dialog.exec() != QDialog::Accepted || dialog.factor() == 1.0) {
return;
}
m_elmt_scene->undoStack().push(
new ScaleElementCommand(m_elmt_scene, dialog.factor(), dialog.scaleText()));
}
void QETElementEditor::on_m_import_scaled_element_triggered()
{
if (ElementScalerIsPresent(true, this))
+2
View File
@@ -112,6 +112,7 @@ class QETElementEditor : public QMainWindow
void on_m_about_qt_action_triggered();
void on_m_import_dxf_triggered();
void on_m_import_scaled_element_triggered();
void scaleElement();
private:
bool canClose();
@@ -142,6 +143,7 @@ class QETElementEditor : public QMainWindow
QList<QAction *> m_context_menu_action_list;
QAction
*m_scale_element_action = nullptr,
*m_undo_action = nullptr,
*m_redo_action = nullptr;
+98
View File
@@ -0,0 +1,98 @@
/*
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 "scaleelementdialog.h"
#include "../symbolscale.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLocale>
#include <QPushButton>
#include <QVBoxLayout>
/**
@brief ScaleElementDialog::ScaleElementDialog
@param terminals : positions of the element's terminals, in scene
coordinates (relative to the hotspot)
@param parent
*/
ScaleElementDialog::ScaleElementDialog(const QList<QPointF> &terminals, QWidget *parent) :
QDialog(parent)
{
setWindowTitle(tr("Mettre l'élément à l'échelle"));
auto layout = new QVBoxLayout(this);
auto form = new QFormLayout();
layout->addLayout(form);
m_factor = new QComboBox(this);
const auto factors = SymbolScale::safeFactors(terminals);
for (const qreal f : factors) {
m_factor->addItem(QStringLiteral("× %1").arg(QLocale().toString(f)), f);
}
const int two = m_factor->findData(2.0);
if (two >= 0) {
m_factor->setCurrentIndex(two);
}
form->addRow(tr("Facteur :"), m_factor);
m_scale_text = new QCheckBox(tr("Mettre aussi les textes à l'échelle"), this);
m_scale_text->setChecked(true);
form->addRow(m_scale_text);
QString explanation;
const int off_grid = SymbolScale::offGridCount(terminals);
if (off_grid && factors.isEmpty()) {
explanation = tr("%n borne(s) de cet élément ne sont pas sur la grille, "
"et aucun facteur ne les y amène.", "", off_grid);
} else if (off_grid) {
explanation = tr("%n borne(s) de cet élément ne sont pas sur la grille. "
"Seuls les facteurs qui les y amènent sont proposés.", "", off_grid);
} else {
explanation = tr("Seuls les facteurs qui gardent les bornes sur la grille "
"sont proposés. L'élément est mis à l'échelle autour de son point de saisie.");
}
auto label = new QLabel(explanation, this);
label->setWordWrap(true);
layout->addWidget(label);
auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setEnabled(!factors.isEmpty());
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addWidget(buttons);
m_factor->setEnabled(!factors.isEmpty());
m_scale_text->setEnabled(!factors.isEmpty());
}
/**
@return the chosen factor, or 1 if none could be offered
*/
qreal ScaleElementDialog::factor() const
{
return m_factor->count() ? m_factor->currentData().toReal() : 1.0;
}
bool ScaleElementDialog::scaleText() const
{
return m_scale_text->isChecked();
}
+47
View File
@@ -0,0 +1,47 @@
/*
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/>.
*/
#ifndef SCALEELEMENTDIALOG_H
#define SCALEELEMENTDIALOG_H
#include <QDialog>
class QCheckBox;
class QComboBox;
/**
@brief The ScaleElementDialog class
Asks for the factor to scale the whole element by. Only the factors
that leave every terminal on the folio grid are offered
(see SymbolScale::safeFactors()).
*/
class ScaleElementDialog : public QDialog
{
Q_OBJECT
public:
ScaleElementDialog(const QList<QPointF> &terminals, QWidget *parent = nullptr);
qreal factor() const;
bool scaleText() const;
private:
QComboBox *m_factor = nullptr;
QCheckBox *m_scale_text = nullptr;
};
#endif // SCALEELEMENTDIALOG_H
+7
View File
@@ -149,6 +149,13 @@ add_test(NAME tst_terminalnamecheck COMMAND tst_terminalnamecheck)
target_include_directories(tst_terminalnamecheck PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_terminalnamecheck PRIVATE Qt::Test)
# symbolscale.h is header-only: which factors the element editor offers
# for scaling an element without taking its terminals off the grid.
add_executable(tst_symbolscale tst_symbolscale.cpp)
add_test(NAME tst_symbolscale COMMAND tst_symbolscale)
target_include_directories(tst_symbolscale PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_symbolscale PRIVATE Qt::Test)
# configprofile.h is header-only: which settings a saved configuration
# profile carries, and how loading one replaces the live settings.
add_executable(tst_configprofile tst_configprofile.cpp)
+80
View File
@@ -0,0 +1,80 @@
/*
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 <QtTest>
#include "editor/symbolscale.h"
class tst_symbolscale : public QObject
{
Q_OBJECT
private slots:
// Terminals 10 px apart: whole factors are safe, halving is not.
void tenPitchTakesWholeFactors()
{
const QList<QPointF> terminals{{0, -20}, {0, 20}, {10, 0}};
QCOMPARE(SymbolScale::safeFactors(terminals),
(QList<qreal>{2.0, 3.0, 4.0}));
}
// Terminals 20 px apart: every candidate is safe.
void twentyPitchTakesEverything()
{
const QList<QPointF> terminals{{-20, 0}, {20, 40}};
QCOMPARE(SymbolScale::safeFactors(terminals), SymbolScale::candidates());
}
// Off the grid by 5 px today: only even factors bring it on.
void fivePxOffIsRescuedByEvenFactors()
{
const QList<QPointF> terminals{{-5, 0}, {5, 30}};
QVERIFY(!SymbolScale::allOnGrid(terminals));
QCOMPARE(SymbolScale::offGridCount(terminals), 2);
QCOMPARE(SymbolScale::safeFactors(terminals),
(QList<qreal>{2.0, 4.0}));
}
// Off by an odd amount: nothing helps.
void oddOffsetHasNoSafeFactor()
{
QVERIFY(SymbolScale::safeFactors({{-7, 0}, {0, 20}}).isEmpty());
}
void noTerminalTakesEverything()
{
QCOMPARE(SymbolScale::safeFactors({}), SymbolScale::candidates());
}
// Saved with two decimals: 19.999 still counts as 20.
void savedRoundingIsTolerated()
{
QVERIFY(SymbolScale::onGrid(19.999));
QVERIFY(SymbolScale::onGrid(-30.001));
QVERIFY(!SymbolScale::onGrid(19.9));
}
void fontSizeIsWholeAndReadable()
{
QCOMPARE(SymbolScale::scaledFontSize(9, 2.0), 18);
QCOMPARE(SymbolScale::scaledFontSize(9, 1.5), 14);
QCOMPARE(SymbolScale::scaledFontSize(6, 0.5), 4);
}
};
QTEST_APPLESS_MAIN(tst_symbolscale)
#include "tst_symbolscale.moc"