diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index dfa30c109..d899f6779 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -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 diff --git a/sources/editor/editorcommands.cpp b/sources/editor/editorcommands.cpp index 62bc2a013..864e78944 100644 --- a/sources/editor/editorcommands.cpp +++ b/sources/editor/editorcommands.cpp @@ -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(item)) { + addChange(text, "font", scaledFont(text->font())); + addChange(text, "pos", text->pos() * factor); + } + else if (auto field = qgraphicsitem_cast(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(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(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); + } +} diff --git a/sources/editor/editorcommands.h b/sources/editor/editorcommands.h index e5a811a83..0cbcf923e 100644 --- a/sources/editor/editorcommands.h +++ b/sources/editor/editorcommands.h @@ -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 m_geometry_parts; + /// Values set directly, in the order they are applied + QList m_changes; +}; + #endif diff --git a/sources/editor/symbolscale.h b/sources/editor/symbolscale.h new file mode 100644 index 000000000..e6dab4e4a --- /dev/null +++ b/sources/editor/symbolscale.h @@ -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 . +*/ +#ifndef SYMBOLSCALE_H +#define SYMBOLSCALE_H + +#include +#include +#include + +/** + 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 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 &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 &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 safeFactors(const QList &terminals, int grid_size = grid) + { + QList safe; + for (const qreal factor : candidates()) { + QList 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 diff --git a/sources/editor/ui/qetelementeditor.cpp b/sources/editor/ui/qetelementeditor.cpp index af39d81bb..5f65ee09e 100644 --- a/sources/editor/ui/qetelementeditor.cpp +++ b/sources/editor/ui/qetelementeditor.cpp @@ -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 @@ -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 terminals; + for (CustomElementPart *part : m_elmt_scene->primitives()) { + if (auto terminal = qgraphicsitem_cast(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)) diff --git a/sources/editor/ui/qetelementeditor.h b/sources/editor/ui/qetelementeditor.h index b164017f8..f74de2a23 100644 --- a/sources/editor/ui/qetelementeditor.h +++ b/sources/editor/ui/qetelementeditor.h @@ -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 m_context_menu_action_list; QAction + *m_scale_element_action = nullptr, *m_undo_action = nullptr, *m_redo_action = nullptr; diff --git a/sources/editor/ui/scaleelementdialog.cpp b/sources/editor/ui/scaleelementdialog.cpp new file mode 100644 index 000000000..2be5b2230 --- /dev/null +++ b/sources/editor/ui/scaleelementdialog.cpp @@ -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 . +*/ +#include "scaleelementdialog.h" + +#include "../symbolscale.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief ScaleElementDialog::ScaleElementDialog + @param terminals : positions of the element's terminals, in scene + coordinates (relative to the hotspot) + @param parent +*/ +ScaleElementDialog::ScaleElementDialog(const QList &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(); +} diff --git a/sources/editor/ui/scaleelementdialog.h b/sources/editor/ui/scaleelementdialog.h new file mode 100644 index 000000000..419c14933 --- /dev/null +++ b/sources/editor/ui/scaleelementdialog.h @@ -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 . +*/ +#ifndef SCALEELEMENTDIALOG_H +#define SCALEELEMENTDIALOG_H + +#include + +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 &terminals, QWidget *parent = nullptr); + + qreal factor() const; + bool scaleText() const; + + private: + QComboBox *m_factor = nullptr; + QCheckBox *m_scale_text = nullptr; +}; + +#endif // SCALEELEMENTDIALOG_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 683b745f0..ef75840e6 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -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) diff --git a/tests/qttest/tst_symbolscale.cpp b/tests/qttest/tst_symbolscale.cpp new file mode 100644 index 000000000..d99981ee2 --- /dev/null +++ b/tests/qttest/tst_symbolscale.cpp @@ -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 . +*/ +#include + +#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 terminals{{0, -20}, {0, 20}, {10, 0}}; + QCOMPARE(SymbolScale::safeFactors(terminals), + (QList{2.0, 3.0, 4.0})); + } + + // Terminals 20 px apart: every candidate is safe. + void twentyPitchTakesEverything() + { + const QList 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 terminals{{-5, 0}, {5, 30}}; + QVERIFY(!SymbolScale::allOnGrid(terminals)); + QCOMPARE(SymbolScale::offGridCount(terminals), 2); + QCOMPARE(SymbolScale::safeFactors(terminals), + (QList{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"