Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1793dcb9a8 | |||
| cc7aaf3538 | |||
| a6285c7ef7 | |||
| f87381a910 | |||
| bd5cff4211 | |||
| 296f142036 | |||
| 4d991d0419 | |||
| a85bd6a49b | |||
| ef8e41a5c3 | |||
| 6f80da66d5 | |||
| a065867502 | |||
| b98d41357a | |||
| b2f4ef5d25 | |||
| bd8470afa8 | |||
| 7c5f8c6a4b | |||
| 6b27043f33 | |||
| ed5a510592 | |||
| 3abf187688 | |||
| 825a1a594d | |||
| 38e1b3fe49 | |||
| fd3e213222 | |||
| ef4e186e42 | |||
| 975ab31cc1 | |||
| 2699a0aec8 | |||
| 76ff5c4dd9 | |||
| b395527915 | |||
| 7741bb33b2 | |||
| f02b576cb8 | |||
| e0ef24fe68 | |||
| 1e1039dd32 | |||
| e410896f5d | |||
| 10bf0411bb | |||
| e3cb994e0e | |||
| 0d08a4e265 | |||
| ae6b992ae4 |
@@ -3,6 +3,8 @@
|
||||
"runtime": "org.kde.Platform",
|
||||
"runtime-version": "6.11",
|
||||
"sdk": "org.kde.Sdk",
|
||||
"base": "io.qt.qtwebengine.BaseApp",
|
||||
"base-version": "6.11",
|
||||
"command": "qelectrotech",
|
||||
"rename-desktop-file": "org.qelectrotech.qelectrotech.desktop",
|
||||
"rename-appdata-file": "qelectrotech.appdata.xml",
|
||||
@@ -25,13 +27,14 @@
|
||||
"*.la",
|
||||
"*.a"
|
||||
],
|
||||
"cleanup-commands": [
|
||||
"/app/cleanup-BaseApp.sh"
|
||||
],
|
||||
"modules": [
|
||||
"tkinter.json",
|
||||
"pypi-dependencies.json",
|
||||
{
|
||||
"name": "qelectrotech",
|
||||
"//qt6-private-headers-note": "qt6-base-private-dev has no Flatpak build-depends equivalent — private Qt6 headers ship inside org.kde.Sdk itself. If the cmake build fails looking for QtCore/private/*.h, the SDK/runtime branch is mismatched with the source tree, not a missing package.",
|
||||
"//sqlite-driver-note": "libqt6sql6-sqlite / libsqlite3-dev have no Flatpak build-depends equivalent either. QET_EXPORT_PROJECT_DB=ON assumes the KDE runtime ships the Qt6 SQLite plugin (libqsqlite.so) already built into QtSql — verified at Debian packaging time this needed an explicit runtime dep there (see debian/control). Test the in-app database export after building; if it silently fails, the runtime's QtSql plugin set needs checking, not a package to add here.",
|
||||
"buildsystem": "cmake",
|
||||
"config-opts": [
|
||||
"-DCMAKE_INSTALL_PREFIX=/app",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
<g transform="translate(1,1)">
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m6 14c0 2.77 2.23 5 5 5s5-2.23 5-5z" fill-rule="evenodd" opacity=".66"/>
|
||||
<path d="m7.482 10c-.34.586-.65 1.148-.886 1.676a4.958 4.958 0 0 0 -.596 2.324h10c0-.845-.228-1.627-.596-2.324a18.518 18.518 0 0 0 -.886-1.676z" fill-rule="evenodd" opacity=".33"/>
|
||||
<path d="m11 3c-.973 3.405-3.354 6.332-4.404 8.676a4.958 4.958 0 0 0 -.596 2.324c0 2.77 2.23 5 5 5s5-2.23 5-5c0-.845-.228-1.627-.596-2.324-1.05-2.344-3.431-5.271-4.404-8.676zm0 2.393c1.194 2.668 2.747 5.027 3.492 6.691l.012.03.016.029a3.94 3.94 0 0 1 .48 1.857c0 2.233-1.767 4-4 4a3.97 3.97 0 0 1 -4-4c0-.665.177-1.283.48-1.857l.016-.03.012-.029c.745-1.664 2.298-4.023 3.492-6.691z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 983 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4 2 L 4 3 L 5 3 L 5 2 L 4 2 z M 11 2 L 11 3 L 12 3 L 12 2 L 11 2 z M 2 4 L 2 12 L 7 12 L 7 10 L 9 10 L 9 12 L 14 12 L 14 4 L 9 4 L 9 6 L 7 6 L 7 4 L 2 4 z M 3 5 L 6 5 L 6 11 L 3 11 L 3 5 z M 10 5 L 13 5 L 13 11 L 10 11 L 10 5 z M 11 6 L 11 10 L 12 10 L 12 6 L 11 6 z M 7 7 L 9 7 L 9 9 L 7 9 L 7 7 z M 4 13 L 4 14 L 5 14 L 5 13 L 4 13 z M 11 13 L 11 14 L 12 14 L 12 13 L 11 13 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4 2 L 4 7 L 6 7 L 6 9 L 4 9 L 4 14 L 12 14 L 12 9 L 10 9 L 10 7 L 12 7 L 12 2 L 4 2 z M 5 3 L 11 3 L 11 6 L 5 6 L 5 3 z M 2 4 L 2 5 L 3 5 L 3 4 L 2 4 z M 13 4 L 13 5 L 14 5 L 14 4 L 13 4 z M 7 7 L 9 7 L 9 9 L 7 9 L 7 7 z M 5 10 L 11 10 L 11 13 L 5 13 L 5 10 z M 2 11 L 2 12 L 3 12 L 3 11 L 2 11 z M 6 11 L 6 12 L 10 12 L 10 11 L 6 11 z M 13 11 L 13 12 L 14 12 L 14 11 L 13 11 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 3 3 L 3 5 L 3 6 L 6 6 L 6 5 L 6 3 L 4 3 L 3 3 z M 16 3 L 16 4 L 16 5 L 16 6 L 17 6 L 18 6 L 19 6 L 19 5 L 19 4 L 19 3 L 16 3 z M 4 4 L 5 4 L 5 5 L 4 5 L 4 4 z M 17 4 L 18 4 L 18 5 L 17 5 L 17 4 z M 7 5 L 7 6 L 9 6 L 9 5 L 7 5 z M 10 5 L 10 6 L 12 6 L 12 5 L 10 5 z M 13 5 L 13 6 L 15 6 L 15 5 L 13 5 z M 5 7 L 5 9 L 6 9 L 6 7 L 5 7 z M 16 7 L 16 9 L 17 9 L 17 7 L 16 7 z M 5 10 L 5 12 L 6 12 L 6 10 L 5 10 z M 16 10 L 16 12 L 17 12 L 17 10 L 16 10 z M 5 13 L 5 15 L 6 15 L 6 13 L 5 13 z M 16 13 L 16 15 L 17 15 L 17 13 L 16 13 z M 3 16 L 3 17 L 3 19 L 4 19 L 5 19 L 6 19 L 6 16 L 3 16 z M 7 16 L 7 17 L 9 17 L 9 16 L 7 16 z M 10 16 L 10 17 L 12 17 L 12 16 L 10 16 z M 13 16 L 13 17 L 15 17 L 15 16 L 13 16 z M 16 16 L 16 17 L 16 18 L 16 19 L 17 19 L 18 19 L 19 19 L 19 18 L 19 17 L 19 16 L 17 16 L 16 16 z M 4 17 L 5 17 L 5 18 L 4 18 L 4 17 z M 17 17 L 18 17 L 18 18 L 17 18 L 17 17 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -549,6 +549,13 @@
|
||||
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-project.svgz</file>
|
||||
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-titleblock.svgz</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/transform-crop.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/edit-opacity.svg</file>
|
||||
<file>ico/generated/rect-to-polyline.svg</file>
|
||||
<file>ico/generated/rect-to-bezier.svg</file>
|
||||
<file>ico/generated/ellipse-to-bezier.svg</file>
|
||||
<file>ico/16x16/object-group.png</file>
|
||||
<file>ico/mac_icon/elmt.icns</file>
|
||||
<file>ico/mac_icon/qelectrotech.icns</file>
|
||||
|
||||
@@ -72,6 +72,7 @@ QPropertyUndoCommand::QPropertyUndoCommand(const QPropertyUndoCommand *other)
|
||||
m_new_value = other->m_new_value;
|
||||
m_animate = other->m_animate;
|
||||
m_first_time = other->m_first_time;
|
||||
m_undo_first_time = other->m_undo_first_time;
|
||||
setText(other->text());
|
||||
}
|
||||
|
||||
@@ -99,11 +100,20 @@ void QPropertyUndoCommand::enableAnimation (bool animate) {
|
||||
@param first_time = if true,
|
||||
the first animation is done at the first call of redo if false,
|
||||
the first animation is done at the second call of redo.
|
||||
The same rule applies to undo, tracked independently: redo() always
|
||||
runs before the first undo() (QUndoStack::push() calls redo()
|
||||
immediately), so by the time undo() can run at all, redo()'s own
|
||||
m_first_time has already flipped true. Sharing that flag would leave
|
||||
undo() with no instant path ever reachable in practice -- reusing it
|
||||
is not actually symmetric, it just looks like it is. m_undo_first_time
|
||||
gives undo() the same one-time grace period redo() has, on its own
|
||||
first call instead of redo's.
|
||||
*/
|
||||
void QPropertyUndoCommand::setAnimated(bool animate, bool first_time)
|
||||
{
|
||||
m_animate = animate;
|
||||
m_first_time = first_time;
|
||||
m_undo_first_time = first_time;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +128,18 @@ bool QPropertyUndoCommand::mergeWith(const QUndoCommand *other)
|
||||
QPropertyUndoCommand const *undo = static_cast<const QPropertyUndoCommand *>(other);
|
||||
if (m_object != undo->m_object
|
||||
|| m_property_name != undo->m_property_name) return false;
|
||||
// Same object and property name alone isn't enough: two entirely
|
||||
// separate, deliberate actions (say, cropping an image and then
|
||||
// mirroring it) both go through the same "pixmap" property and
|
||||
// would otherwise silently coalesce into one undo entry, carrying
|
||||
// only the first action's label -- the second vanishes from the
|
||||
// undo list with no way to undo just it. Legitimate merging (a
|
||||
// slider or spinbox pushing one command per tick while being
|
||||
// dragged, e.g. ArcEditor's angle editors) always reuses the exact
|
||||
// same text() across the whole sequence, so requiring a match here
|
||||
// keeps that working unchanged while refusing to merge anything
|
||||
// that isn't actually a continuation of the same action.
|
||||
if (text() != other->text()) return false;
|
||||
m_new_value = undo->m_new_value;
|
||||
return true;
|
||||
}
|
||||
@@ -155,7 +177,7 @@ void QPropertyUndoCommand::undo()
|
||||
{
|
||||
if (m_object->property(m_property_name) != m_old_value)
|
||||
{
|
||||
if (m_animate)
|
||||
if (m_animate && m_undo_first_time)
|
||||
{
|
||||
QPropertyAnimation *animation = new QPropertyAnimation(m_object, m_property_name);
|
||||
animation->setStartValue(m_new_value);
|
||||
@@ -163,7 +185,10 @@ void QPropertyUndoCommand::undo()
|
||||
animation->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_object->setProperty(m_property_name, m_old_value);
|
||||
m_undo_first_time = true;
|
||||
}
|
||||
}
|
||||
|
||||
QUndoCommand::undo();
|
||||
|
||||
@@ -58,7 +58,8 @@ class QPropertyUndoCommand : public QUndoCommand
|
||||
const char *m_property_name;
|
||||
QVariant m_old_value, m_new_value;
|
||||
bool m_animate = false,
|
||||
m_first_time = true;
|
||||
m_first_time = true,
|
||||
m_undo_first_time = true;
|
||||
};
|
||||
|
||||
#endif // QPROPERTYUNDOCOMMAND_H
|
||||
|
||||
@@ -225,12 +225,57 @@ void projectDataBase::addDiagram(Diagram *diagram)
|
||||
|
||||
void projectDataBase::removeDiagram(Diagram *diagram)
|
||||
{
|
||||
m_remove_diagram_query.bindValue(":uuid", diagram->uuid().toString());
|
||||
const QString uuid_str = diagram->uuid().toString();
|
||||
|
||||
//Order matters: element_info and terminal are scoped through a
|
||||
//subquery on element, so they must run before element itself is
|
||||
//deleted below. The whole cascade runs in one transaction and is
|
||||
//rolled back on the first error, so a mid-cascade failure (e.g. a
|
||||
//locked DB) can't leave the diagram row deleted while its
|
||||
//element/terminal/element_info/conductor rows survive.
|
||||
m_data_base.transaction();
|
||||
|
||||
m_cascade_remove_element_info_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_cascade_remove_element_info_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram element_info cascade error : "
|
||||
<< m_cascade_remove_element_info_query.lastError();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
m_cascade_remove_terminal_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_cascade_remove_terminal_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram terminal cascade error : "
|
||||
<< m_cascade_remove_terminal_query.lastError();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
m_cascade_remove_conductor_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_cascade_remove_conductor_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram conductor cascade error : "
|
||||
<< m_cascade_remove_conductor_query.lastError();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
m_cascade_remove_element_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_cascade_remove_element_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram element cascade error : "
|
||||
<< m_cascade_remove_element_query.lastError();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
m_remove_diagram_query.bindValue(":uuid", uuid_str);
|
||||
if (!m_remove_diagram_query.exec()) {
|
||||
qDebug() << "projectDataBase::removeDiagram delete error : " << m_remove_diagram_query.lastError();
|
||||
} else {
|
||||
emit dataBaseUpdated();
|
||||
m_data_base.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
m_data_base.commit();
|
||||
emit dataBaseUpdated();
|
||||
}
|
||||
|
||||
void projectDataBase::diagramInfoChanged(Diagram *diagram)
|
||||
@@ -772,7 +817,27 @@ void projectDataBase::prepareQuery()
|
||||
m_insert_diagram_query = QSqlQuery(m_data_base);
|
||||
m_insert_diagram_query.prepare("INSERT INTO diagram (uuid, pos) VALUES (:uuid, :pos)");
|
||||
|
||||
//REMOVE DIAGRAM
|
||||
//REMOVE DIAGRAM (cascade first: element_info and terminal have no
|
||||
//diagram_uuid column of their own, so both are scoped through
|
||||
//element while the element rows for this diagram still exist).
|
||||
m_cascade_remove_element_info_query = QSqlQuery(m_data_base);
|
||||
m_cascade_remove_element_info_query.prepare(
|
||||
"DELETE FROM element_info WHERE element_uuid IN "
|
||||
"(SELECT uuid FROM element WHERE diagram_uuid = :uuid)");
|
||||
|
||||
m_cascade_remove_terminal_query = QSqlQuery(m_data_base);
|
||||
m_cascade_remove_terminal_query.prepare(
|
||||
"DELETE FROM terminal WHERE element_uuid IN "
|
||||
"(SELECT uuid FROM element WHERE diagram_uuid = :uuid)");
|
||||
|
||||
m_cascade_remove_conductor_query = QSqlQuery(m_data_base);
|
||||
m_cascade_remove_conductor_query.prepare(
|
||||
"DELETE FROM conductor WHERE diagram_uuid = :uuid");
|
||||
|
||||
m_cascade_remove_element_query = QSqlQuery(m_data_base);
|
||||
m_cascade_remove_element_query.prepare(
|
||||
"DELETE FROM element WHERE diagram_uuid = :uuid");
|
||||
|
||||
m_remove_diagram_query = QSqlQuery(m_data_base);
|
||||
m_remove_diagram_query.prepare("DELETE FROM diagram WHERE uuid=:uuid");
|
||||
|
||||
|
||||
@@ -106,7 +106,11 @@ class projectDataBase : public QObject
|
||||
m_insert_terminal_query,
|
||||
m_insert_conductor_query,
|
||||
m_update_conductor_query,
|
||||
m_remove_conductor_query;
|
||||
m_remove_conductor_query,
|
||||
m_cascade_remove_element_info_query,
|
||||
m_cascade_remove_terminal_query,
|
||||
m_cascade_remove_conductor_query,
|
||||
m_cascade_remove_element_query;
|
||||
|
||||
#ifdef QET_EXPORT_PROJECT_DB
|
||||
public:
|
||||
|
||||
@@ -40,9 +40,53 @@
|
||||
#include "undocommand/addelementtextcommand.h"
|
||||
#include "qetinformation.h"
|
||||
#include "qetproject.h"
|
||||
#include "diagramsortkeys.h"
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <math.h>
|
||||
|
||||
namespace {
|
||||
using DiagramSortKeys::positionKey;
|
||||
|
||||
/// Sort key for Diagram::toXml()'s <elements> block: the element's own
|
||||
/// diagram-local position, exactly what it's already saved as (x/y),
|
||||
/// never invented or regenerated. uuid() is deliberately NOT used here:
|
||||
/// for an element with no persisted uuid attribute, fromXml() invents a
|
||||
/// fresh random one on every load, so sorting by uuid would still be
|
||||
/// non-deterministic across process runs for any legacy file.
|
||||
QString elementSortKey(Element *elmt)
|
||||
{
|
||||
return positionKey(elmt->pos());
|
||||
}
|
||||
|
||||
/// Sort key for a terminal: its parent element's position, then the
|
||||
/// terminal's own position local to that element (from the .elmt
|
||||
/// definition, fixed regardless of where the element is placed).
|
||||
QString terminalSortKey(Terminal *terminal)
|
||||
{
|
||||
if (!terminal)
|
||||
return QString();
|
||||
Element *parent = terminal->parentElement();
|
||||
return (parent ? positionKey(parent->pos()) : QStringLiteral("?"))
|
||||
+ QLatin1Char(':') + positionKey(terminal->pos());
|
||||
}
|
||||
|
||||
/// Sort key for Diagram::toXml()'s <conductors> block. Built from both
|
||||
/// endpoints' terminalSortKey(), not Conductor::uuid(): in every example
|
||||
/// project checked, conductors have no persisted uuid attribute at all,
|
||||
/// so uuid() is a freshly-minted random value on every load -- exactly
|
||||
/// as unusable for cross-run determinism as the element case above, just
|
||||
/// with no persisted fallback to reach for instead. Canonicalised
|
||||
/// (smaller key first) since a conductor's two ends are unordered for
|
||||
/// this purpose.
|
||||
QString conductorSortKey(Conductor *cond)
|
||||
{
|
||||
QString a = terminalSortKey(cond->terminal1);
|
||||
QString b = terminalSortKey(cond->terminal2);
|
||||
return (a <= b) ? (a + QLatin1Char('>') + b) : (b + QLatin1Char('>') + a);
|
||||
}
|
||||
}
|
||||
|
||||
int Diagram::xGrid = 10;
|
||||
int Diagram::yGrid = 10;
|
||||
int Diagram::xKeyGrid = 10;
|
||||
@@ -1014,6 +1058,20 @@ QDomDocument Diagram::toXml(bool whole_content, bool is_copy_command) {
|
||||
}
|
||||
}
|
||||
|
||||
// items() returns items in stacking order, which is not guaranteed
|
||||
// reproducible across processes (ties between same-Z items follow
|
||||
// the scene's internal index, not any content-derived order) -- so
|
||||
// without this, saving an unmodified project produces a different
|
||||
// byte stream on every run. Elements and conductors are the two
|
||||
// blocks observed to actually churn across the example corpus;
|
||||
// sort them into a deterministic, content-derived order before
|
||||
// serializing. This also fixes the legacy terminal-id churn below,
|
||||
// since those ids are assigned sequentially in element order.
|
||||
std::stable_sort(list_elements.begin(), list_elements.end(),
|
||||
[](Element *a, Element *b) { return elementSortKey(a) < elementSortKey(b); });
|
||||
std::stable_sort(list_conductors.begin(), list_conductors.end(),
|
||||
[](Conductor *a, Conductor *b) { return conductorSortKey(a) < conductorSortKey(b); });
|
||||
|
||||
// correspondence table between the addresses of the terminals and their ids
|
||||
// table de correspondance entre les adresses des bornes et leurs ids
|
||||
QHash<Terminal *, int> table_adr_id;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
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 DIAGRAMSORTKEYS_H
|
||||
#define DIAGRAMSORTKEYS_H
|
||||
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
/// Sort-key helpers used by Diagram::toXml() to serialize elements and
|
||||
/// conductors in a deterministic order. Pulled into their own header
|
||||
/// (instead of an anonymous namespace in diagram.cpp) so they can be
|
||||
/// unit-tested directly -- see tests/qttest/tst_diagramsortkeys.cpp.
|
||||
namespace DiagramSortKeys {
|
||||
|
||||
/// Format a coordinate as a string that sorts the same way the number
|
||||
/// does. Plain fixed-precision formatting ("%.4f") does NOT do this --
|
||||
/// e.g. "15.0000" sorts before "5.0000" as text even though 15 > 5 --
|
||||
/// so shift into a non-negative range and zero-pad to a fixed width
|
||||
/// before comparing.
|
||||
inline QString coordinateKey(double v)
|
||||
{
|
||||
//Diagram coordinates are nowhere near this range; the offset and
|
||||
//width just need to be big enough that shifted values are always
|
||||
//non-negative and always the same digit count.
|
||||
constexpr double offset = 1e9;
|
||||
qint64 scaled = qint64(qRound64((v + offset) * 10000.0));
|
||||
return QStringLiteral("%1").arg(scaled, 20, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
inline QString positionKey(const QPointF &pos)
|
||||
{
|
||||
return coordinateKey(pos.x()) + QLatin1Char('|') + coordinateKey(pos.y());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -200,18 +200,35 @@ void DxfPaintEngine::drawTextItem(const QPointF &p, const QTextItem &textItem)
|
||||
|
||||
/**
|
||||
@brief DxfPaintEngine::drawPixmap
|
||||
Not implemented in v1 - out of scope per the design note in the
|
||||
header (CrossRefItem, the only item exported through this engine so
|
||||
far, never draws a pixmap). qWarning() rather than a hard failure, so
|
||||
an item that does call this in the future degrades to "one entity
|
||||
missing" instead of crashing the whole export.
|
||||
No DXF dialect this old (AC1006, AutoCAD R10 from 1988) has any
|
||||
raster image representation at all -- IMAGE/IMAGEDEF wasn't
|
||||
introduced until R2000, over a decade later, and even there the
|
||||
pixels are never embedded, only referenced by external file path.
|
||||
Actually supporting images means upgrading the DXF version target
|
||||
and managing a second file alongside the DXF; until then, this
|
||||
draws a placeholder rectangle outline (the item's own destination
|
||||
rect, mapped through m_world_transform exactly like drawRects()
|
||||
does) instead of silently dropping the item -- so its position,
|
||||
size, rotation, and skew all survive the export even though the
|
||||
picture itself can't yet. A fixed, plain pen, not m_pen: an image
|
||||
item has no meaningful pen of its own for this engine to have
|
||||
picked up from a prior updateState().
|
||||
*/
|
||||
void DxfPaintEngine::drawPixmap(const QRectF &r, const QPixmap &pm, const QRectF &sr)
|
||||
{
|
||||
Q_UNUSED(r)
|
||||
Q_UNUSED(pm)
|
||||
Q_UNUSED(sr)
|
||||
qWarning() << "DxfPaintEngine::drawPixmap: not supported, entity skipped";
|
||||
|
||||
QPolygonF corners;
|
||||
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
||||
|
||||
QPolygonF mapped;
|
||||
mapped.reserve(corners.size() + 1);
|
||||
for (const QPointF &p : corners)
|
||||
mapped << toDxf(p);
|
||||
mapped << mapped.first();
|
||||
|
||||
Createdxf::drawPolyline(m_filepath, mapped, Createdxf::dxfColor(QPen(Qt::black)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,8 +278,21 @@ int DxfPaintDevice::metric(PaintDeviceMetric metric) const
|
||||
case PdmDepth:
|
||||
return 24;
|
||||
case PdmDevicePixelRatio:
|
||||
case PdmDevicePixelRatioScaled:
|
||||
return 1;
|
||||
case PdmDevicePixelRatioScaled:
|
||||
// Not just 1: Qt's own convention (see QPaintDevice's docs)
|
||||
// is that this metric equals PdmDevicePixelRatio scaled by
|
||||
// devicePixelRatioFScale() -- a large constant (65536)
|
||||
// used internally for sub-integer precision. Returning the
|
||||
// plain, unscaled ratio here (as this used to) meant
|
||||
// QPainter's own transform bookkeeping divided by it
|
||||
// expecting the scaled value, silently shrinking every
|
||||
// drawPixmap() destination rect by a factor of 65536 --
|
||||
// this is what was actually behind the placeholder
|
||||
// rectangle rendering at a barely-visible fraction of its
|
||||
// real size instead of the size the item's own
|
||||
// sceneTransform() correctly specified.
|
||||
return int(1 * QPaintDevice::devicePixelRatioFScale());
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,18 @@
|
||||
- fillPath -> same outline-only handling as drawRects; no HATCH
|
||||
support in v1 (see design note in the PR)
|
||||
|
||||
Anything outside this list (images, gradients, etc.) is intentionally
|
||||
unimplemented and asserts in debug builds rather than silently
|
||||
producing an incomplete drawing - callers should know immediately if
|
||||
an item they're exporting uses something this engine doesn't cover
|
||||
yet, rather than getting a DXF file quietly missing content.
|
||||
- drawPixmap -> no raster image entity exists in this DXF
|
||||
dialect at all (see drawPixmap()'s own
|
||||
comment) -- draws a placeholder rectangle
|
||||
outline instead, preserving the item's
|
||||
position/size/rotation/skew even though the
|
||||
picture itself can't be included yet
|
||||
|
||||
Anything else outside this list (gradients, etc.) is intentionally
|
||||
unimplemented and logs a warning rather than silently producing an
|
||||
incomplete drawing - callers should know immediately if an item
|
||||
they're exporting uses something this engine doesn't cover yet,
|
||||
rather than getting a DXF file quietly missing content.
|
||||
*/
|
||||
class DxfPaintEngine : public QPaintEngine
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "editorcommands.h"
|
||||
#include "../diagram.h"
|
||||
|
||||
/**
|
||||
@brief ElementEditionCommand::ElementEditionCommand
|
||||
@@ -666,10 +667,32 @@ void RotateFineElementsCommand::redo()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@brief selectionCenter
|
||||
@param items
|
||||
@return the center of the united scene bounding rect of items,
|
||||
snapped to the nearest half of the diagram grid. Mirroring across a
|
||||
half-grid line keeps points that were on the grid on the grid.
|
||||
*/
|
||||
static QPointF selectionCenter(const QList<QGraphicsItem *> &items)
|
||||
{
|
||||
QRectF bounding;
|
||||
for (auto *item : items) {
|
||||
bounding = bounding.united(item->sceneBoundingRect());
|
||||
}
|
||||
QPointF center = bounding.center();
|
||||
const qreal half_x = Diagram::xGrid / 2.0;
|
||||
const qreal half_y = Diagram::yGrid / 2.0;
|
||||
center.setX(qRound(center.x() / half_x) * half_x);
|
||||
center.setY(qRound(center.y() / half_y) * half_y);
|
||||
return center;
|
||||
}
|
||||
|
||||
MirrorElementsCommand::MirrorElementsCommand(ElementScene *scene, QUndoCommand *parent) :
|
||||
ElementEditionCommand(QObject::tr("Miroir de sélection", "undo caption"), scene, nullptr, parent)
|
||||
{
|
||||
m_items = scene->selectedItems();
|
||||
m_axis_x = selectionCenter(m_items).x();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -680,28 +703,28 @@ void MirrorElementsCommand::redo()
|
||||
foreach (auto *item, m_items) {
|
||||
if (item->type() == PartText::Type) {
|
||||
PartText* staticText = qgraphicsitem_cast<PartText*>(item);
|
||||
staticText->mirror();
|
||||
staticText->mirror(m_axis_x);
|
||||
} else if (item->type() == PartDynamicTextField::Type) {
|
||||
PartDynamicTextField* dyntext = qgraphicsitem_cast<PartDynamicTextField*>(item);
|
||||
dyntext->mirror();
|
||||
dyntext->mirror(m_axis_x);
|
||||
} else if (item->type() == PartArc::Type) {
|
||||
PartArc* arc = qgraphicsitem_cast<PartArc*>(item);
|
||||
arc->mirror();
|
||||
arc->mirror(m_axis_x);
|
||||
} else if (item->type() == PartEllipse::Type) {
|
||||
PartEllipse* ellipse = qgraphicsitem_cast<PartEllipse*>(item);
|
||||
ellipse->mirror();
|
||||
ellipse->mirror(m_axis_x);
|
||||
} else if (item->type() == PartLine::Type) {
|
||||
PartLine* line = qgraphicsitem_cast<PartLine*>(item);
|
||||
line->mirror();
|
||||
line->mirror(m_axis_x);
|
||||
} else if (item->type() == PartPolygon::Type) {
|
||||
PartPolygon* poly = qgraphicsitem_cast<PartPolygon*>(item);
|
||||
poly->mirror();
|
||||
poly->mirror(m_axis_x);
|
||||
} else if (item->type() == PartRectangle::Type) {
|
||||
PartRectangle* rect = qgraphicsitem_cast<PartRectangle*>(item);
|
||||
rect->mirror();
|
||||
rect->mirror(m_axis_x);
|
||||
} else if (item->type() == PartTerminal::Type) {
|
||||
PartTerminal* term = qgraphicsitem_cast<PartTerminal*>(item);
|
||||
term->mirror();
|
||||
term->mirror(m_axis_x);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,6 +741,7 @@ FlipElementsCommand::FlipElementsCommand(ElementScene *scene, QUndoCommand *pare
|
||||
ElementEditionCommand(QObject::tr("Retourner la sélection", "undo caption"), scene, nullptr, parent)
|
||||
{
|
||||
m_items = scene->selectedItems();
|
||||
m_axis_y = selectionCenter(m_items).y();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -728,28 +752,28 @@ void FlipElementsCommand::redo()
|
||||
foreach (auto *item, m_items) {
|
||||
if (item->type() == PartText::Type) {
|
||||
PartText* staticText = qgraphicsitem_cast<PartText*>(item);
|
||||
staticText->flip();
|
||||
staticText->flip(m_axis_y);
|
||||
} else if (item->type() == PartDynamicTextField::Type) {
|
||||
PartDynamicTextField* dyntext = qgraphicsitem_cast<PartDynamicTextField*>(item);
|
||||
dyntext->flip();
|
||||
dyntext->flip(m_axis_y);
|
||||
} else if (item->type() == PartArc::Type) {
|
||||
PartArc* arc = qgraphicsitem_cast<PartArc*>(item);
|
||||
arc->flip();
|
||||
arc->flip(m_axis_y);
|
||||
} else if (item->type() == PartEllipse::Type) {
|
||||
PartEllipse* ellipse = qgraphicsitem_cast<PartEllipse*>(item);
|
||||
ellipse->flip();
|
||||
ellipse->flip(m_axis_y);
|
||||
} else if (item->type() == PartLine::Type) {
|
||||
PartLine* line = qgraphicsitem_cast<PartLine*>(item);
|
||||
line->flip();
|
||||
line->flip(m_axis_y);
|
||||
} else if (item->type() == PartPolygon::Type) {
|
||||
PartPolygon* poly = qgraphicsitem_cast<PartPolygon*>(item);
|
||||
poly->flip();
|
||||
poly->flip(m_axis_y);
|
||||
} else if (item->type() == PartRectangle::Type) {
|
||||
PartRectangle* rect = qgraphicsitem_cast<PartRectangle*>(item);
|
||||
rect->flip();
|
||||
rect->flip(m_axis_y);
|
||||
} else if (item->type() == PartTerminal::Type) {
|
||||
PartTerminal* term = qgraphicsitem_cast<PartTerminal*>(item);
|
||||
term->flip();
|
||||
term->flip(m_axis_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,12 @@ private:
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
@brief The MirrorElementsCommand class
|
||||
Mirror the selected parts horizontally (left <-> right) across the
|
||||
vertical line through the center of the selection, so the selection
|
||||
keeps its place in the scene.
|
||||
*/
|
||||
class MirrorElementsCommand : public ElementEditionCommand
|
||||
{
|
||||
public:
|
||||
@@ -288,8 +294,15 @@ public:
|
||||
private:
|
||||
ElementScene *m_scene =nullptr;
|
||||
QList<QGraphicsItem*> m_items;
|
||||
qreal m_axis_x = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
@brief The FlipElementsCommand class
|
||||
Flip the selected parts vertically (top <-> bottom) across the
|
||||
horizontal line through the center of the selection, so the selection
|
||||
keeps its place in the scene.
|
||||
*/
|
||||
class FlipElementsCommand : public ElementEditionCommand
|
||||
{
|
||||
public:
|
||||
@@ -299,6 +312,7 @@ public:
|
||||
private:
|
||||
ElementScene *m_scene =nullptr;
|
||||
QList<QGraphicsItem*> m_items;
|
||||
qreal m_axis_y = 0;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -197,13 +197,13 @@ qreal PartArc::rotation() const {
|
||||
return qRound(m_rot * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
void PartArc::flip() {
|
||||
void PartArc::flip(qreal axis_y) {
|
||||
m_start_angle = (-1) * m_start_angle;
|
||||
m_span_angle = (-1) * m_span_angle;
|
||||
while (m_start_angle < 0) { m_start_angle += (360*16); }
|
||||
while (m_start_angle >= (360*16)) { m_start_angle -= (360*16); }
|
||||
auto p1 = mapToScene(m_rect.x(),m_rect.y());
|
||||
p1.setY(((-1.0) * p1.y()) - m_rect.height());
|
||||
p1.setY(2 * axis_y - p1.y() - m_rect.height());
|
||||
p1 = mapFromScene(p1.x(),p1.y());
|
||||
m_rect = QRectF(m_rect.x(), p1.y(), m_rect.width(), m_rect.height());
|
||||
prepareGeometryChange();
|
||||
@@ -211,13 +211,13 @@ void PartArc::flip() {
|
||||
emit rectChanged();
|
||||
}
|
||||
|
||||
void PartArc::mirror() {
|
||||
void PartArc::mirror(qreal axis_x) {
|
||||
m_start_angle = (180.0 * 16) - m_start_angle;
|
||||
m_span_angle = (-1) * m_span_angle;
|
||||
while (m_start_angle < 0) { m_start_angle += (360*16); }
|
||||
while (m_start_angle >= (360*16)) { m_start_angle -= (360*16); }
|
||||
auto p1 = mapToScene(m_rect.x(),m_rect.y());
|
||||
p1.setX(((-1.0) * p1.x()) - m_rect.width());
|
||||
p1.setX(2 * axis_x - p1.x() - m_rect.width());
|
||||
p1 = mapFromScene(p1.x(), p1.y());
|
||||
m_rect = QRectF(p1.x(), m_rect.y(), m_rect.width(), m_rect.height());
|
||||
prepareGeometryChange();
|
||||
|
||||
@@ -62,8 +62,8 @@ class PartArc : public AbstractPartEllipse
|
||||
QRectF sceneGeometricRect() const override;
|
||||
void setRotation(qreal angle);
|
||||
qreal rotation() const;
|
||||
void flip();
|
||||
void mirror();
|
||||
void flip(qreal axis_y = 0);
|
||||
void mirror(qreal axis_x = 0);
|
||||
|
||||
void addHandler() override;
|
||||
void removeHandler() override;
|
||||
|
||||
@@ -74,19 +74,19 @@ void PartDynamicTextField::setRotation(qreal angle) {
|
||||
setPos(QTransform().rotate(diffAngle).map(pos()));
|
||||
}
|
||||
|
||||
void PartDynamicTextField::mirror() {
|
||||
void PartDynamicTextField::mirror(qreal axis_x) {
|
||||
// at first: rotate the text:
|
||||
QGraphicsObject::setRotation(QET::correctAngle(360-rotation(), true));
|
||||
// then see, where we need to re-position depending on the angle!
|
||||
qreal rot = qRound(QET::correctAngle(rotation(), true));
|
||||
qreal c = qCos(qDegreesToRadians(rot));
|
||||
qreal s = qSin(qDegreesToRadians(rot));
|
||||
qreal x = (-1) * pos().x() - c * boundingRect().width();
|
||||
qreal x = 2 * axis_x - pos().x() - c * boundingRect().width();
|
||||
qreal y = pos().y() - s * boundingRect().width();
|
||||
setPos(x, y);
|
||||
}
|
||||
|
||||
void PartDynamicTextField::flip() {
|
||||
void PartDynamicTextField::flip(qreal axis_y) {
|
||||
// at first: rotate the text:
|
||||
QGraphicsObject::setRotation(QET::correctAngle(360-rotation(), true));
|
||||
// then see, where we need to re-position depending on the angle!
|
||||
@@ -94,7 +94,7 @@ void PartDynamicTextField::flip() {
|
||||
qreal c = qCos(qDegreesToRadians(rot));
|
||||
qreal s = qSin(qDegreesToRadians(rot));
|
||||
qreal x = pos().x() + s * boundingRect().height();
|
||||
qreal y = (-1) * pos().y() - c * boundingRect().height();
|
||||
qreal y = 2 * axis_y - pos().y() - c * boundingRect().height();
|
||||
setPos(x, y);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,8 +106,8 @@ class PartDynamicTextField : public QGraphicsTextItem, public CustomElementPart
|
||||
bool rotationPointCenter() const;
|
||||
|
||||
void setRotation(qreal angle);
|
||||
void mirror();
|
||||
void flip();
|
||||
void mirror(qreal axis_x = 0);
|
||||
void flip(qreal axis_y = 0);
|
||||
|
||||
|
||||
protected:
|
||||
|
||||
@@ -256,9 +256,9 @@ qreal PartEllipse::rotation() const {
|
||||
return qRound(m_rot * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
void PartEllipse::flip() {
|
||||
void PartEllipse::flip(qreal axis_y) {
|
||||
auto p1 = mapToScene(m_rect.x(), m_rect.y());
|
||||
p1.setY(((-1.0) * p1.y()) - m_rect.height());
|
||||
p1.setY(2 * axis_y - p1.y() - m_rect.height());
|
||||
p1 = mapFromScene(p1.x(), p1.y());
|
||||
m_rect = QRectF(p1.x(), p1.y(), m_rect.width(), m_rect.height());
|
||||
prepareGeometryChange();
|
||||
@@ -266,9 +266,9 @@ void PartEllipse::flip() {
|
||||
emit rectChanged();
|
||||
}
|
||||
|
||||
void PartEllipse::mirror() {
|
||||
void PartEllipse::mirror(qreal axis_x) {
|
||||
auto p1 = mapToScene(m_rect.x(), m_rect.y());
|
||||
p1.setX(((-1.0) * p1.x()) - m_rect.width());
|
||||
p1.setX(2 * axis_x - p1.x() - m_rect.width());
|
||||
p1 = mapFromScene(p1.x(), p1.y());
|
||||
m_rect = QRectF(p1.x(), p1.y(), m_rect.width(), m_rect.height());
|
||||
prepareGeometryChange();
|
||||
|
||||
@@ -62,8 +62,8 @@ class PartEllipse : public AbstractPartEllipse
|
||||
void setRect(const QRectF &rect) override {AbstractPartEllipse::setRect(rect); adjustHandlerPos();}
|
||||
void setRotation(qreal angle);
|
||||
qreal rotation() const;
|
||||
void flip();
|
||||
void mirror();
|
||||
void flip(qreal axis_y = 0);
|
||||
void mirror(qreal axis_x = 0);
|
||||
|
||||
void addHandler() override;
|
||||
void removeHandler() override;
|
||||
|
||||
@@ -590,11 +590,11 @@ qreal PartLine::rotation() const {
|
||||
return qRound(m_rot * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
void PartLine::flip() {
|
||||
void PartLine::flip(qreal axis_y) {
|
||||
auto p1 = mapToScene(m_line.p1());
|
||||
auto p2 = mapToScene(m_line.p2());
|
||||
p1 = QPointF(p1.x(), (-1) * p1.y());
|
||||
p2 = QPointF(p2.x(), (-1) * p2.y());
|
||||
p1 = QPointF(p1.x(), 2 * axis_y - p1.y());
|
||||
p2 = QPointF(p2.x(), 2 * axis_y - p2.y());
|
||||
m_line.setP1(mapFromScene(p1));
|
||||
m_line.setP2(mapFromScene(p2));
|
||||
setLine(m_line);
|
||||
@@ -603,11 +603,11 @@ void PartLine::flip() {
|
||||
emit lineChanged();
|
||||
}
|
||||
|
||||
void PartLine::mirror() {
|
||||
void PartLine::mirror(qreal axis_x) {
|
||||
auto p1 = mapToScene(m_line.p1());
|
||||
auto p2 = mapToScene(m_line.p2());
|
||||
p1 = QPointF((-1) * p1.x(), p1.y());
|
||||
p2 = QPointF((-1) * p2.x(), p2.y());
|
||||
p1 = QPointF(2 * axis_x - p1.x(), p1.y());
|
||||
p2 = QPointF(2 * axis_x - p2.x(), p2.y());
|
||||
m_line.setP1(mapFromScene(p1));
|
||||
m_line.setP2(mapFromScene(p2));
|
||||
setLine(m_line);
|
||||
|
||||
@@ -96,8 +96,8 @@ class PartLine : public CustomElementGraphicPart
|
||||
void setSecondEndLength(const qreal &l);
|
||||
void setRotation(qreal angle);
|
||||
qreal rotation() const;
|
||||
void flip();
|
||||
void mirror();
|
||||
void flip(qreal axis_y = 0);
|
||||
void mirror(qreal axis_x = 0);
|
||||
|
||||
void addHandler() override;
|
||||
void removeHandler() override;
|
||||
|
||||
@@ -307,10 +307,10 @@ qreal PartPolygon::rotation() const {
|
||||
return qRound(m_rot * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
void PartPolygon::flip() {
|
||||
void PartPolygon::flip(qreal axis_y) {
|
||||
for (auto &pt : m_polygon) {
|
||||
pt = mapToScene(pt.x(), pt.y());
|
||||
pt = QPointF(pt.x(), (-1) * pt.y());
|
||||
pt = QPointF(pt.x(), 2 * axis_y - pt.y());
|
||||
pt = mapFromScene(pt.x(), pt.y());
|
||||
}
|
||||
setPolygon(m_polygon);
|
||||
@@ -319,10 +319,10 @@ void PartPolygon::flip() {
|
||||
emit polygonChanged();
|
||||
}
|
||||
|
||||
void PartPolygon::mirror() {
|
||||
void PartPolygon::mirror(qreal axis_x) {
|
||||
for (auto &pt : m_polygon) {
|
||||
pt = mapToScene(pt.x(), pt.y());
|
||||
pt = QPointF((-1) * pt.x(), pt.y());
|
||||
pt = QPointF(2 * axis_x - pt.x(), pt.y());
|
||||
pt = mapFromScene(pt.x(), pt.y());
|
||||
}
|
||||
setPolygon(m_polygon);
|
||||
|
||||
@@ -89,8 +89,8 @@ class PartPolygon : public CustomElementGraphicPart
|
||||
|
||||
void setRotation (qreal angle);
|
||||
qreal rotation () const;
|
||||
void flip();
|
||||
void mirror();
|
||||
void flip(qreal axis_y = 0);
|
||||
void mirror(qreal axis_x = 0);
|
||||
|
||||
void addHandler() override;
|
||||
void removeHandler() override;
|
||||
|
||||
@@ -187,11 +187,11 @@ qreal PartRectangle::rotation() const {
|
||||
return qRound(m_rot * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
void PartRectangle::flip() {
|
||||
void PartRectangle::flip(qreal axis_y) {
|
||||
auto height = m_rect.height();
|
||||
auto p1 = mapToScene(m_rect.x(),m_rect.y());
|
||||
qreal x = p1.x();
|
||||
qreal y = ((-1.0) * p1.y()) - height;
|
||||
qreal y = 2 * axis_y - p1.y() - height;
|
||||
p1 = mapFromScene(x, y);
|
||||
m_rect.setX(p1.x());
|
||||
m_rect.setY(p1.y());
|
||||
@@ -201,10 +201,10 @@ void PartRectangle::flip() {
|
||||
emit rectChanged();
|
||||
}
|
||||
|
||||
void PartRectangle::mirror() {
|
||||
void PartRectangle::mirror(qreal axis_x) {
|
||||
auto width = m_rect.width();
|
||||
auto p1 = mapToScene(m_rect.x(),m_rect.y());
|
||||
qreal x = ((-1.0) * p1.x()) - width;
|
||||
qreal x = 2 * axis_x - p1.x() - width;
|
||||
qreal y = p1.y();
|
||||
p1 = mapFromScene(x, y);
|
||||
m_rect.setX(p1.x());
|
||||
|
||||
@@ -73,8 +73,8 @@ class PartRectangle : public CustomElementGraphicPart
|
||||
void setYRadius(qreal Y);
|
||||
void setRotation(qreal angle);
|
||||
qreal rotation() const;
|
||||
void flip();
|
||||
void mirror();
|
||||
void flip(qreal axis_y = 0);
|
||||
void mirror(qreal axis_x = 0);
|
||||
|
||||
QRectF sceneGeometricRect() const override;
|
||||
virtual QPointF sceneTopLeft() const;
|
||||
|
||||
@@ -293,9 +293,9 @@ qreal PartTerminal::rotation() const {
|
||||
@brief PartTerminal::flip
|
||||
turn part upside down
|
||||
*/
|
||||
void PartTerminal::flip() {
|
||||
void PartTerminal::flip(qreal axis_y) {
|
||||
d->m_pos.setX( pos().x());
|
||||
d->m_pos.setY((-1.0) * pos().y());
|
||||
d->m_pos.setY(2 * axis_y - pos().y());
|
||||
switch (d->m_orientation) {
|
||||
case Qet::North : setOrientation(Qet::South);
|
||||
break;
|
||||
@@ -314,8 +314,8 @@ void PartTerminal::flip() {
|
||||
@brief PartTerminal::mirror
|
||||
turn part from left to right
|
||||
*/
|
||||
void PartTerminal::mirror() {
|
||||
d->m_pos.setX((-1.0) * pos().x());
|
||||
void PartTerminal::mirror(qreal axis_x) {
|
||||
d->m_pos.setX(2 * axis_x - pos().x());
|
||||
d->m_pos.setY( pos().y());
|
||||
switch (d->m_orientation) {
|
||||
case Qet::North : break;
|
||||
|
||||
@@ -96,8 +96,8 @@ class PartTerminal : public CustomElementGraphicPart
|
||||
|
||||
void setRotation(qreal angle);
|
||||
qreal rotation() const;
|
||||
void flip();
|
||||
void mirror();
|
||||
void flip(qreal axis_y = 0);
|
||||
void mirror(qreal axis_x = 0);
|
||||
void nextOrientation();
|
||||
void previousOrientation();
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ void PartText::setRotation(qreal angle) {
|
||||
setPos(QTransform().rotate(diffAngle).map(pos()));
|
||||
}
|
||||
|
||||
void PartText::mirror() {
|
||||
void PartText::mirror(qreal axis_x) {
|
||||
// at first: rotate the text:
|
||||
QGraphicsObject::setRotation(QET::correctAngle((360-rotation()), true));
|
||||
// then see, where we need to re-position depending on text, font ...
|
||||
@@ -86,12 +86,12 @@ void PartText::mirror() {
|
||||
qreal c = qCos(qDegreesToRadians(rot));
|
||||
qreal s = qSin(qDegreesToRadians(rot));
|
||||
// Now: Move!
|
||||
qreal x = (-1) * pos().x() - c * (textwidth);
|
||||
qreal x = 2 * axis_x - pos().x() - c * (textwidth);
|
||||
qreal y = pos().y() - s * (textwidth);
|
||||
setPos(x, y);
|
||||
}
|
||||
|
||||
void PartText::flip() {
|
||||
void PartText::flip(qreal axis_y) {
|
||||
// at first: rotate the text:
|
||||
QGraphicsObject::setRotation(QET::correctAngle((360-rotation()), true));
|
||||
// then see, where we need to re-position depending on text, font ...
|
||||
@@ -103,7 +103,7 @@ void PartText::flip() {
|
||||
qreal s = qSin(qDegreesToRadians(rot));
|
||||
// Now: Move!
|
||||
qreal x = pos().x() - s * (textheight);
|
||||
qreal y = (-1) * pos().y() + c * (textheight);
|
||||
qreal y = 2 * axis_y - pos().y() + c * (textheight);
|
||||
setPos(x, y);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ class PartText : public QGraphicsTextItem, public CustomElementPart {
|
||||
void fromXml(const QDomElement &) override;
|
||||
const QDomElement toXml(QDomDocument &) const override;
|
||||
void setRotation(qreal angle);
|
||||
void mirror();
|
||||
void flip();
|
||||
void mirror(qreal axis_x = 0);
|
||||
void flip(qreal axis_y = 0);
|
||||
bool isUseless() const override;
|
||||
QRectF sceneGeometricRect() const override;
|
||||
void startUserTransformation(const QRectF &) override;
|
||||
|
||||
@@ -744,6 +744,25 @@ void ExportDialog::generateDxf(
|
||||
painter.end();
|
||||
}
|
||||
|
||||
//Draw images -- collected above (list_images) but never actually
|
||||
//drawn until now, an existing gap this reuses the same paint()
|
||||
//-replay approach to fix: DiagramImageItem::paint() has no
|
||||
//viewport-dependent logic (unlike CrossRefItem, which needs its own
|
||||
//paintForExport() for that reason), so it's called directly with a
|
||||
//default QStyleOptionGraphicsItem rather than needing an export-
|
||||
//specific variant of its own. DxfPaintEngine::drawPixmap() is what
|
||||
//actually turns the drawPixmap() call inside paint() into a
|
||||
//placeholder outline, since this DXF dialect has no raster image
|
||||
//entity to draw instead.
|
||||
for (DiagramImageItem *image : std::as_const(list_images))
|
||||
{
|
||||
DxfPaintDevice dxf_device(file_path);
|
||||
QPainter painter(&dxf_device);
|
||||
painter.setWorldTransform(image->sceneTransform());
|
||||
image->paintForExport(&painter);
|
||||
painter.end();
|
||||
}
|
||||
|
||||
Createdxf::dxfEnd(file_path);
|
||||
|
||||
saveReloadDiagramParameters(diagram, false);
|
||||
@@ -818,6 +837,42 @@ void ExportDialog::slot_export()
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn once, up front, rather than per-diagram: this DXF dialect
|
||||
// (AC1006, AutoCAD R10) has no raster image representation at all
|
||||
// (IMAGE/IMAGEDEF wasn't introduced until R2000, over a decade
|
||||
// later, and even there the picture is never embedded, only
|
||||
// referenced by external file path) -- so any image ends up as a
|
||||
// placeholder rectangle outline instead (see DxfPaintEngine::
|
||||
// drawPixmap()), with its position/size/rotation/skew preserved but
|
||||
// not its actual content.
|
||||
if (epw -> exportProperties().format.compare(QLatin1String("dxf"), Qt::CaseInsensitive) == 0)
|
||||
{
|
||||
bool any_images = false;
|
||||
for (ExportDiagramLine *diagram_line : std::as_const(diagrams_to_export))
|
||||
{
|
||||
for (QGraphicsItem *item : diagram_line->diagram->items())
|
||||
{
|
||||
if (qgraphicsitem_cast<DiagramImageItem *>(item)) {
|
||||
any_images = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (any_images) break;
|
||||
}
|
||||
|
||||
if (any_images)
|
||||
{
|
||||
QET::QetMessageBox::warning(
|
||||
this,
|
||||
tr("Images non incluses dans l'export DXF", "message box title"),
|
||||
tr("Le format DXF utilisé ici (AC1006) ne permet pas d'inclure d'image. "
|
||||
"Les images seront représentées uniquement par un rectangle de contour "
|
||||
"(position, taille, rotation et inclinaison conservées), sans le contenu de l'image.",
|
||||
"message box content")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// exporte chaque schema a exporter
|
||||
foreach(ExportDiagramLine *diagram_line, diagrams_to_export) {
|
||||
exportDiagram(diagram_line);
|
||||
|
||||
@@ -297,11 +297,19 @@ QString QET::ElementsAndConductorsSentence(
|
||||
|
||||
if (images_count) {
|
||||
if (!text.isEmpty()) text += ", ";
|
||||
text += QObject::tr(
|
||||
"%n image(s)",
|
||||
"part of a sentence listing the content of a diagram",
|
||||
images_count
|
||||
);
|
||||
// Qt's %n only selects a grammatical singular/plural form (the
|
||||
// "(s)" convention used by every other count here) -- it never
|
||||
// spells the number out as a word, so getting "une image"
|
||||
// instead of the literal "1 image" for the single-item case
|
||||
// means handling that count outside %n entirely, with its own
|
||||
// fixed string.
|
||||
text += images_count == 1
|
||||
? QObject::tr("une image", "part of a sentence listing the content of a diagram")
|
||||
: QObject::tr(
|
||||
"%n images",
|
||||
"part of a sentence listing the content of a diagram",
|
||||
images_count
|
||||
);
|
||||
}
|
||||
|
||||
if (shapes_count) {
|
||||
|
||||
@@ -1011,10 +1011,14 @@ bool Conductor::fromXml(QDomElement &dom_element)
|
||||
//that field was introduced (see terminal1/terminal2 handling in
|
||||
//toXml() below).
|
||||
m_uuid = QUuid(dom_element.attribute(QStringLiteral("uuid")));
|
||||
m_persist_uuid = !m_uuid.isNull();
|
||||
if (m_uuid.isNull()) {
|
||||
//Absent, empty or malformed: mint one. A null uuid is not a usable
|
||||
//identity -- every conductor carrying one would collide with every
|
||||
//other on the conductor table's primary key.
|
||||
//other on the conductor table's primary key. It's runtime-only,
|
||||
//though: toXml() must not write it back out, or a legacy file
|
||||
//with no conductor uuids gets a freshly different one on every
|
||||
//single load-and-resave (see #754).
|
||||
m_uuid = QUuid::createUuid();
|
||||
}
|
||||
|
||||
@@ -1056,7 +1060,8 @@ QDomElement Conductor::toXml(QDomDocument &dom_document,
|
||||
{
|
||||
QDomElement dom_element = dom_document.createElement("conductor");
|
||||
|
||||
dom_element.setAttribute("uuid", m_uuid.toString());
|
||||
if (m_persist_uuid)
|
||||
dom_element.setAttribute("uuid", m_uuid.toString());
|
||||
dom_element.setAttribute("x", QString::number(pos().x()));
|
||||
dom_element.setAttribute("y", QString::number(pos().y()));
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class Conductor : public QGraphicsObject
|
||||
Diagram *diagram() const;
|
||||
ConductorTextItem *textItem() const;
|
||||
QUuid uuid() const {return m_uuid;}
|
||||
void newUuid() {m_uuid = QUuid::createUuid();} //create new uuid for this conductor
|
||||
void newUuid() {m_uuid = QUuid::createUuid(); m_persist_uuid = true;} //create new uuid for this conductor
|
||||
void updatePath(const QRectF & = QRectF());
|
||||
|
||||
//This method do nothing, it's only made to be used with Q_PROPERTY
|
||||
@@ -209,6 +209,10 @@ class Conductor : public QGraphicsObject
|
||||
bool m_valid;
|
||||
bool m_freeze_label = false;
|
||||
QUuid m_uuid;
|
||||
/// false when m_uuid was synthesized by fromXml() because the
|
||||
/// file had none -- toXml() must not persist that value, or
|
||||
/// every reload mints and saves a new random one (see #754).
|
||||
bool m_persist_uuid = true;
|
||||
|
||||
/// QPen et QBrush objects used to draw conductors
|
||||
static QPen conductor_pen;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "../qet.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetdiagrameditor.h"
|
||||
#include "../qeticons.h"
|
||||
#include "../ui/imagepropertieswidget.h"
|
||||
#include "../ui/imagecropdialog.h"
|
||||
#include "../ui/imagetransparentcolordialog.h"
|
||||
@@ -31,10 +32,14 @@
|
||||
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QBuffer>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QGraphicsSceneContextMenuEvent>
|
||||
#include <QImageWriter>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QTextStream>
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::DiagramImageItem
|
||||
@@ -811,11 +816,16 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
else
|
||||
undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY);
|
||||
}
|
||||
if (undo)
|
||||
undo->setText(tr("Redimensionner une image"));
|
||||
break;
|
||||
|
||||
case HandleRole::Rotate:
|
||||
if (!qFuzzyCompare(m_transform.rotation, m_original_transform.rotation))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "rotationAngle", m_original_transform.rotation, m_transform.rotation);
|
||||
undo->setText(tr("Faire pivoter une image"));
|
||||
}
|
||||
break;
|
||||
|
||||
case HandleRole::SkewEdge:
|
||||
@@ -823,6 +833,8 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
undo = new QPropertyUndoCommand(this, "skewX", m_original_transform.skewX, m_transform.skewX);
|
||||
else if (!qFuzzyCompare(m_transform.skewY, m_original_transform.skewY))
|
||||
undo = new QPropertyUndoCommand(this, "skewY", m_original_transform.skewY, m_transform.skewY);
|
||||
if (undo)
|
||||
undo->setText(tr("Incliner une image"));
|
||||
break;
|
||||
|
||||
case HandleRole::Pivot:
|
||||
@@ -837,8 +849,28 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
|
||||
if (undo)
|
||||
{
|
||||
// Defensive fallback only -- every role above now sets its
|
||||
// own, distinct label directly (Resize/Rotate/SkewEdge used
|
||||
// to all fall through to this same generic text, making the
|
||||
// undo list unable to tell three completely different edits
|
||||
// apart); this only still matters if some future role is
|
||||
// ever added without setting one of its own.
|
||||
if (undo->text().isEmpty())
|
||||
undo->setText(tr("Modifier une image"));
|
||||
|
||||
// Every push here is one complete, finished gesture (press,
|
||||
// drag, release) -- never a continuation of an earlier one.
|
||||
// QPropertyUndoCommand::mergeWith() already treats any
|
||||
// command with children as never mergeable; a dummy child
|
||||
// guarantees that here regardless of which role produced
|
||||
// undo. Without this, a single-property change (Rotate and
|
||||
// SkewEdge always are; Resize sometimes is, when only one
|
||||
// axis actually changed) would silently coalesce into
|
||||
// whatever identically-labelled edit came right before it
|
||||
// -- even after a deselect/reselect proved they were two
|
||||
// separate actions, since the shared label alone is
|
||||
// otherwise indistinguishable from a genuine continuation.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1061,177 @@ void DiagramImageItem::restoreAspectRatio()
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::saveImageAs
|
||||
Saves the currently displayed pixmap (crop and colour-keyed
|
||||
transparency already applied -- what the item actually looks like
|
||||
on the diagram, not the pristine original) to an arbitrary file on
|
||||
disk.
|
||||
*/
|
||||
void DiagramImageItem::saveImageAs()
|
||||
{
|
||||
saveImagePixmapAs(pixmap_, tr("Enregistrer l'image sous..."), !m_transparent_colors.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::saveOriginalImageAs
|
||||
Saves m_base_pixmap -- the true, pristine original, before any crop
|
||||
or colour-keyed transparency -- rather than the item's current,
|
||||
possibly-cropped-and-keyed display pixmap. The only way to recover
|
||||
the un-cropped, un-keyed source once either of those has actually
|
||||
been applied, short of undoing every edit back to the point it was
|
||||
first inserted or replaced.
|
||||
*/
|
||||
void DiagramImageItem::saveOriginalImageAs()
|
||||
{
|
||||
saveImagePixmapAs(m_base_pixmap, tr("Enregistrer l'image d'origine sous..."), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::saveImagePixmapAs
|
||||
Shared by saveImageAs() and saveOriginalImageAs(): prompts for a
|
||||
destination, resolves whichever format was actually intended, warns
|
||||
before silently dropping transparency, and writes the file. A
|
||||
read-only export, not an edit: doesn't touch diagram()'s undo stack,
|
||||
and works even on a read-only diagram, unlike every other action in
|
||||
this item's context menu.
|
||||
@param pixmap the pixmap to save -- pixmap_ or m_base_pixmap
|
||||
@param dialogTitle distinguishes the two callers in the save dialog's own title bar
|
||||
@param hasTransparency whether `pixmap` has colour-keyed transparency worth warning about losing (never true for the pristine original, which predates any such keying)
|
||||
*/
|
||||
void DiagramImageItem::saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency)
|
||||
{
|
||||
QWidget *parentWidget = (diagram() && !diagram()->views().isEmpty()) ? diagram()->views().first() : nullptr;
|
||||
|
||||
// Filter text -> extension. Built once and used both to construct
|
||||
// the dialog's filter list and, below, to resolve whichever one the
|
||||
// person actually had selected -- rather than only ever falling
|
||||
// back to a single hardcoded format regardless of their choice,
|
||||
// which is what this used to do (getSaveFileName() doesn't reliably
|
||||
// auto-append the selected filter's extension on every platform,
|
||||
// and the previous version's fallback ignored the selected filter
|
||||
// entirely, defaulting to PNG even when JPEG or BMP had been
|
||||
// explicitly chosen).
|
||||
const QList<QPair<QString, QString>> filters = {
|
||||
{tr("Image PNG (*.png)"), QStringLiteral("png")},
|
||||
{tr("Image JPEG (*.jpg *.jpeg)"), QStringLiteral("jpg")},
|
||||
{tr("Image BMP (*.bmp)"), QStringLiteral("bmp")},
|
||||
// SVG here always means a raster image wrapped in an SVG
|
||||
// container (an <image> element embedding this same pixmap as
|
||||
// base64 PNG), never a true vector export -- this item only
|
||||
// ever holds raster data, even when originally inserted from an
|
||||
// SVG file, since that file was rasterized once at import time
|
||||
// and its vector information is already gone by the time this
|
||||
// runs.
|
||||
{tr("Image SVG (*.svg)"), QStringLiteral("svg")},
|
||||
};
|
||||
QStringList filterStrings;
|
||||
for (const auto &f : filters)
|
||||
filterStrings << f.first;
|
||||
filterStrings << tr("Tous les fichiers (*)");
|
||||
|
||||
QString selectedFilter;
|
||||
QString path = QFileDialog::getSaveFileName(
|
||||
parentWidget, dialogTitle, QString(), filterStrings.join(QLatin1String(";;")), &selectedFilter);
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
|
||||
// Resolve the target format: prefer an extension already present
|
||||
// and actually writable, otherwise fall back to whichever filter
|
||||
// was selected in the dialog (not a fixed default), so the format
|
||||
// picked in the dropdown is the one that's actually honoured even
|
||||
// when the typed name carries no extension of its own.
|
||||
QString suffix = QFileInfo(path).suffix().toLower();
|
||||
const bool suffixIsSvg = (suffix == QLatin1String("svg"));
|
||||
const bool suffixIsWritableRaster = QImageWriter::supportedImageFormats().contains(suffix.toUtf8());
|
||||
if (!suffixIsSvg && !suffixIsWritableRaster)
|
||||
{
|
||||
QString fallbackExt = QStringLiteral("png");
|
||||
for (const auto &f : filters)
|
||||
if (f.first == selectedFilter) { fallbackExt = f.second; break; }
|
||||
path += QLatin1Char('.') + fallbackExt;
|
||||
suffix = fallbackExt;
|
||||
}
|
||||
|
||||
// JPEG and BMP have no usable alpha channel here -- Qt's writers for
|
||||
// both silently drop it. That alone wouldn't be too bad (the
|
||||
// warning already tells the person transparency won't survive),
|
||||
// except pixmap_ itself has already lost the true colour
|
||||
// information wherever it's transparent by this point: QPixmap
|
||||
// stores its data premultiplied internally, which zeroes out RGB
|
||||
// at alpha=0 the moment applyColorKey()'s QImage result gets
|
||||
// wrapped into a QPixmap -- confirmed directly, not assumed
|
||||
// (QPixmap::fromImage() on a white, alpha=0 pixel reliably comes
|
||||
// back black on toImage()). Saving pixmap_ as-is to a format with
|
||||
// no alpha channel doesn't fall back to the original picture, it
|
||||
// reveals that already-lost black. The fix below substitutes the
|
||||
// cropped base pixmap for the save in this specific case -- it has
|
||||
// the same, correct RGB values everywhere applyColorKey() left a
|
||||
// pixel opaque (colour-keying only ever changes alpha, never RGB),
|
||||
// so it's an exact reproduction of what the image looked like
|
||||
// before any colour was ever keyed out, not an approximation.
|
||||
const bool formatPreservesAlpha = (suffix == QLatin1String("png") || suffix == QLatin1String("svg"));
|
||||
QPixmap toSave = pixmap;
|
||||
if (hasTransparency && !formatPreservesAlpha)
|
||||
{
|
||||
if (QMessageBox::warning(parentWidget,
|
||||
tr("Transparence non conservée"),
|
||||
tr("Ce format ne prend pas en charge la transparence : l'image sera enregistrée "
|
||||
"telle qu'elle était avant l'application de la couleur transparente. Continuer ?"),
|
||||
QMessageBox::Yes | QMessageBox::Cancel) != QMessageBox::Yes)
|
||||
return;
|
||||
|
||||
toSave = m_base_pixmap.copy(m_crop_rect);
|
||||
}
|
||||
|
||||
bool ok;
|
||||
if (suffix == QLatin1String("svg"))
|
||||
ok = writeRasterAsSvg(toSave, path);
|
||||
else
|
||||
ok = toSave.save(path);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
QMessageBox::warning(parentWidget,
|
||||
tr("Échec de l'enregistrement"),
|
||||
tr("Impossible d'enregistrer l'image à cet emplacement."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::writeRasterAsSvg
|
||||
Wraps `pixmap` as a base64-embedded PNG inside a minimal, valid SVG
|
||||
document -- the closest this can honestly offer to "save as SVG"
|
||||
given this item only ever holds raster data (see the note in
|
||||
saveImagePixmapAs()). Opens in any SVG viewer at the pixmap's own
|
||||
pixel size, but is not, and cannot be, a vector re-export.
|
||||
@param pixmap the raster image to embed
|
||||
@param path destination file path
|
||||
@return whether the file was written successfully
|
||||
*/
|
||||
bool DiagramImageItem::writeRasterAsSvg(const QPixmap &pixmap, const QString &path)
|
||||
{
|
||||
QByteArray pngData;
|
||||
QBuffer buffer(&pngData);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
if (!pixmap.save(&buffer, "PNG"))
|
||||
return false;
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||
return false;
|
||||
|
||||
QTextStream out(&file);
|
||||
out << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
<< "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
|
||||
<< "width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
|
||||
<< "viewBox=\"0 0 " << pixmap.width() << ' ' << pixmap.height() << "\">\n"
|
||||
<< " <image width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
|
||||
<< "xlink:href=\"data:image/png;base64," << QString::fromLatin1(pngData.toBase64()) << "\"/>\n"
|
||||
<< "</svg>\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::itemChange
|
||||
*/
|
||||
@@ -1064,23 +1267,24 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant
|
||||
@brief DiagramImageItem::computeDisplayPixmap
|
||||
Re-derives what pixmap_ should be from first principles: crop the
|
||||
true original down to the chosen region, then colour-key whichever
|
||||
colours have been picked out of it. Used whenever crop() or
|
||||
setTransparentColor() changes one of those two independently, so
|
||||
the other's effect is correctly re-applied on top rather than lost
|
||||
or compounded -- cropping after colours were already picked has to
|
||||
still show them keyed out; picking colours after a crop has to only
|
||||
ever consider what's still actually part of the image.
|
||||
colours have been picked out of it, each at its own tolerance. Used
|
||||
whenever crop() or setTransparentColor() changes one of those two
|
||||
independently, so the other's effect is correctly re-applied on top
|
||||
rather than lost or compounded -- cropping after colours were
|
||||
already picked has to still show them keyed out; picking colours
|
||||
after a crop has to only ever consider what's still actually part
|
||||
of the image.
|
||||
@param base the true, uncropped original
|
||||
@param cropRect the region of base to keep, in base's own coordinates
|
||||
@param colors colours to key transparent within the cropped region
|
||||
@param tolerance how loosely to match those colours, 0-100
|
||||
@param colors colours (each with its own tolerance) to key transparent within the cropped region
|
||||
*/
|
||||
QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<QColor> &colors, int tolerance)
|
||||
QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect,
|
||||
const QList<ImageTransparentColorDialog::PickedColor> &colors)
|
||||
{
|
||||
const QPixmap cropped = cropRect == base.rect() ? base : base.copy(cropRect);
|
||||
if (colors.isEmpty())
|
||||
return cropped;
|
||||
return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors, tolerance));
|
||||
return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1142,19 +1346,25 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
|
||||
m_base_pixmap = pixmap;
|
||||
m_crop_rect = pixmap.rect();
|
||||
m_transparent_colors.clear();
|
||||
m_transparent_tolerance = 10;
|
||||
|
||||
const QDomElement colorsElement = e.firstChildElement("transparent_colors");
|
||||
bool hasColors = !colorsElement.isNull();
|
||||
if (hasColors)
|
||||
{
|
||||
m_transparent_tolerance = colorsElement.attribute("tolerance", "10").toInt();
|
||||
// Files saved before per-colour tolerance existed wrote a
|
||||
// single value on the wrapper element itself, shared by every
|
||||
// colour -- kept here purely as the fallback default for a
|
||||
// <color> that doesn't carry its own attribute, which for
|
||||
// those old files is every one of them, exactly reproducing
|
||||
// what they used to do (one tolerance applied to all of them).
|
||||
const int wrapperTolerance = colorsElement.attribute("tolerance", "10").toInt();
|
||||
for (const QDomElement &colorElement : QET::findInDomElement(colorsElement, "color"))
|
||||
{
|
||||
m_transparent_colors.append(QColor(
|
||||
colorElement.attribute("r").toInt(),
|
||||
colorElement.attribute("g").toInt(),
|
||||
colorElement.attribute("b").toInt()));
|
||||
m_transparent_colors.append({
|
||||
QColor(colorElement.attribute("r").toInt(),
|
||||
colorElement.attribute("g").toInt(),
|
||||
colorElement.attribute("b").toInt()),
|
||||
colorElement.attribute("tolerance", QString::number(wrapperTolerance)).toInt()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1296,13 +1506,21 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const
|
||||
if (hasColors)
|
||||
{
|
||||
QDomElement colorsElement = document.createElement("transparent_colors");
|
||||
colorsElement.setAttribute("tolerance", m_transparent_tolerance);
|
||||
for (const QColor &color : m_transparent_colors)
|
||||
// Best-effort fallback for an OLDER version of this same code
|
||||
// (from before per-colour tolerance existed) reading a file
|
||||
// saved by this one: uses the first colour's own tolerance as
|
||||
// a single, plausible value rather than some fixed default,
|
||||
// in case it ever needs to open a file like this. Newer code
|
||||
// (including this version) always prefers each <color>'s own
|
||||
// attribute below over this one.
|
||||
colorsElement.setAttribute("tolerance", m_transparent_colors.first().tolerance);
|
||||
for (const auto &pc : m_transparent_colors)
|
||||
{
|
||||
QDomElement colorElement = document.createElement("color");
|
||||
colorElement.setAttribute("r", color.red());
|
||||
colorElement.setAttribute("g", color.green());
|
||||
colorElement.setAttribute("b", color.blue());
|
||||
colorElement.setAttribute("r", pc.color.red());
|
||||
colorElement.setAttribute("g", pc.color.green());
|
||||
colorElement.setAttribute("b", pc.color.blue());
|
||||
colorElement.setAttribute("tolerance", pc.tolerance);
|
||||
colorsElement.appendChild(colorElement);
|
||||
}
|
||||
result.appendChild(colorsElement);
|
||||
@@ -1367,23 +1585,33 @@ void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
|
||||
QAction *replace = menu.data()->addAction(tr("Remplacer l'image..."));
|
||||
connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage);
|
||||
|
||||
QAction *saveAs = menu.data()->addAction(tr("Enregistrer l'image sous..."));
|
||||
connect(saveAs, &QAction::triggered, this, &DiagramImageItem::saveImageAs);
|
||||
|
||||
QAction *saveOriginalAs = menu.data()->addAction(tr("Enregistrer l'image d'origine sous..."));
|
||||
connect(saveOriginalAs, &QAction::triggered, this, &DiagramImageItem::saveOriginalImageAs);
|
||||
|
||||
QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente..."));
|
||||
transparentColor->setIcon(QET::Icons::EditOpacity);
|
||||
connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor);
|
||||
|
||||
QAction *cropAction = menu.data()->addAction(tr("Rogner..."));
|
||||
cropAction->setIcon(QET::Icons::TransformCrop);
|
||||
connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop);
|
||||
|
||||
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
|
||||
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
|
||||
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
|
||||
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
|
||||
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
|
||||
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
|
||||
|
||||
QAction *restoreRatio = menu.data()->addAction(tr("Restaurer les proportions"));
|
||||
connect(restoreRatio, &QAction::triggered, this, &DiagramImageItem::restoreAspectRatio);
|
||||
|
||||
menu.data()->addSeparator();
|
||||
QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
|
||||
// menu.data()->addSeparator();
|
||||
// QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
// connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
|
||||
|
||||
menu.data()->addSeparator();
|
||||
menu.data()->addActions(d_view->contextMenuActions());
|
||||
@@ -1439,6 +1667,15 @@ void DiagramImageItem::replaceImage()
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
||||
undo->setText(tr("Remplacer une image"));
|
||||
// Every call here is a separate, deliberate menu action with no
|
||||
// compound child of its own (unlike crop(), which always chains a
|
||||
// pos/rawPivot change and is naturally immune) -- two of them in a
|
||||
// row would carry the exact same object, property, and text, which
|
||||
// is indistinguishable from a legitimate merge to
|
||||
// QPropertyUndoCommand::mergeWith(). A dummy child (already treated
|
||||
// as "never merge" by that check) keeps each one its own, separate
|
||||
// undo step regardless.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
@@ -1482,6 +1719,11 @@ void DiagramImageItem::mirror(bool horizontal)
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
||||
undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image"));
|
||||
// See replaceImage()'s identical comment: a separate, deliberate
|
||||
// action with no compound child of its own, so a dummy one is
|
||||
// needed to stop two consecutive same-direction mirrors (identical
|
||||
// object, property, and text) from silently merging into one.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
@@ -1520,18 +1762,22 @@ void DiagramImageItem::setTransparentColor()
|
||||
|
||||
QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
|
||||
const QPixmap croppedBase = m_base_pixmap.copy(m_crop_rect);
|
||||
ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, m_transparent_tolerance, parentWidget);
|
||||
ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, parentWidget);
|
||||
if (dialog.exec() != QDialog::Accepted)
|
||||
return;
|
||||
|
||||
m_transparent_colors = dialog.pickedColors();
|
||||
m_transparent_tolerance = dialog.tolerance();
|
||||
|
||||
const QPixmap oldPixmap = pixmap_;
|
||||
const QPixmap newPixmap = dialog.resultPixmap();
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
||||
undo->setText(tr("Définir une couleur transparente"));
|
||||
// See replaceImage()'s identical comment: a separate, deliberate
|
||||
// action with no compound child of its own, so a dummy one is
|
||||
// needed to stop two consecutive transparency edits (identical
|
||||
// object, property, and text) from silently merging into one.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
@@ -1609,7 +1855,7 @@ void DiagramImageItem::crop()
|
||||
const QPointF oldPos = pos();
|
||||
|
||||
const QPixmap oldPixmap = pixmap_;
|
||||
const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors, m_transparent_tolerance);
|
||||
const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors);
|
||||
m_crop_rect = newCropRect;
|
||||
|
||||
// boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
#include "qetgraphicsitem.h"
|
||||
#include "shapetransform.h"
|
||||
#include "../ui/imagetransparentcolordialog.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QList>
|
||||
#include <QStyleOptionGraphicsItem>
|
||||
#include <QVector>
|
||||
|
||||
class QDomElement;
|
||||
@@ -95,6 +97,19 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
QRectF boundingRect() const override;
|
||||
QString name() const override;
|
||||
|
||||
/// DXF export: replay this item's paint() on an arbitrary QPainter
|
||||
/// (e.g. one targeting DxfPaintDevice). paint() itself stays
|
||||
/// protected, as it should for the normal
|
||||
/// QGraphicsScene/QGraphicsView paint contract - this is a
|
||||
/// deliberate, narrow escape hatch for exporters, not a general
|
||||
/// relaxation of that contract. Matches CrossRefItem::paintForExport()
|
||||
/// exactly, for the identical reason.
|
||||
void paintForExport(QPainter *painter)
|
||||
{
|
||||
QStyleOptionGraphicsItem option;
|
||||
paint(painter, &option, nullptr);
|
||||
}
|
||||
|
||||
qreal scaleFactorX() const { return m_transform.scaleX; }
|
||||
qreal scaleFactorY() const { return m_transform.scaleY; }
|
||||
void setScaleFactorX(qreal factor);
|
||||
@@ -128,7 +143,11 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
void setTransparentColor();
|
||||
void crop();
|
||||
void restoreAspectRatio();
|
||||
static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<QColor> &colors, int tolerance);
|
||||
void saveImageAs();
|
||||
void saveOriginalImageAs();
|
||||
void saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency);
|
||||
static bool writeRasterAsSvg(const QPixmap &pixmap, const QString &path);
|
||||
static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<ImageTransparentColorDialog::PickedColor> &colors);
|
||||
|
||||
void toggleHandleMode();
|
||||
HandleMode nextHandleMode() const;
|
||||
@@ -161,22 +180,22 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
QPixmap pixmap_;
|
||||
// The true, pristine original -- never itself cropped or colour-
|
||||
// keyed. pixmap_ (the displayed result) is always re-derived from
|
||||
// this plus m_crop_rect and m_transparent_colors/tolerance, via
|
||||
// computeDisplayPixmap(). Without keeping this separate, re-opening
|
||||
// either the crop or transparency dialog after using the other
|
||||
// would show an already-modified image as if it were the source --
|
||||
// areas already cropped away or coloured out would be gone for
|
||||
// good, with no way to recover or adjust them, only start over.
|
||||
// Updated by whatever genuinely replaces or reorients the image's
|
||||
// actual content (construction, replaceImage(), and mirror(), which
|
||||
// also mirrors m_crop_rect to keep referring to the same region of
|
||||
// the now-flipped base) -- never by crop() or setTransparentColor()
|
||||
// this plus m_crop_rect and m_transparent_colors (each colour
|
||||
// carrying its own tolerance), via computeDisplayPixmap(). Without
|
||||
// keeping this separate, re-opening either the crop or
|
||||
// transparency dialog after using the other would show an
|
||||
// already-modified image as if it were the source -- areas already
|
||||
// cropped away or coloured out would be gone for good, with no way
|
||||
// to recover or adjust them, only start over. Updated by whatever
|
||||
// genuinely replaces or reorients the image's actual content
|
||||
// (construction, replaceImage(), and mirror(), which also mirrors
|
||||
// m_crop_rect to keep referring to the same region of the
|
||||
// now-flipped base) -- never by crop() or setTransparentColor()
|
||||
// themselves, which only ever change which subset of this base is
|
||||
// shown.
|
||||
QPixmap m_base_pixmap;
|
||||
QRect m_crop_rect; // relative to m_base_pixmap; equals m_base_pixmap.rect() when nothing has been cropped
|
||||
QList<QColor> m_transparent_colors;
|
||||
int m_transparent_tolerance = 10;
|
||||
QList<ImageTransparentColorDialog::PickedColor> m_transparent_colors;
|
||||
|
||||
// Independent scaleX/scaleY here is the actual point of this whole
|
||||
// member: QGraphicsItem::scale() is a single, uniform float, which
|
||||
|
||||
@@ -1097,18 +1097,39 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
|
||||
|
||||
if (canConvertToPath)
|
||||
{
|
||||
QAction *convert = menu.data()->addAction(tr("Convertir en polyligne"));
|
||||
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathExplicitly);
|
||||
// Only a sharp-cornered rectangle converts losslessly to
|
||||
// straight-edged polyline -- an ellipse (or arc) flattened
|
||||
// to its bounding rect's 4 corners wouldn't resemble the
|
||||
// original shape at all, and rounded corners would be
|
||||
// silently squared off. Both of those instead need an
|
||||
// actual Bezier curve to preserve their real geometry; the
|
||||
// label reflects whichever this particular shape will
|
||||
// actually get, rather than always claiming "polyligne"
|
||||
// regardless of what's about to happen.
|
||||
const bool needsBezier = (m_shapeType == Ellipse) || (m_xRadius > 0 || m_yRadius > 0);
|
||||
QAction *convert = menu.data()->addAction(needsBezier ? tr("Convertir en courbe de Bézier") : tr("Convertir en polyligne"));
|
||||
if(needsBezier && m_shapeType == Rectangle) {
|
||||
convert->setIcon(QET::Icons::RectToBezier);
|
||||
}
|
||||
else if(needsBezier && m_shapeType == Ellipse) {
|
||||
convert->setIcon(QET::Icons::EllipseToBezier);
|
||||
}
|
||||
else {
|
||||
convert->setIcon(QET::Icons::RectToPolyline);
|
||||
}
|
||||
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathOrPolygon);
|
||||
}
|
||||
|
||||
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
|
||||
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
|
||||
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
|
||||
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
|
||||
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
|
||||
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
|
||||
|
||||
menu.data()->addSeparator();
|
||||
QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
|
||||
//menu.data()->addSeparator();
|
||||
//QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
//connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
|
||||
|
||||
menu.data()->addSeparator();
|
||||
menu.data()->addActions(d_view->contextMenuActions());
|
||||
@@ -1929,34 +1950,207 @@ void QetShapeItem::removePathPoint(int nodeIndex)
|
||||
rebuildHandles();
|
||||
}
|
||||
|
||||
void QetShapeItem::convertToPathExplicitly()
|
||||
/**
|
||||
@brief QetShapeItem::convertToPathOrPolygon
|
||||
Context-menu action, explicitly requested rather than triggered by an
|
||||
Alt+drag (see promoteRectangleOrEllipseToPolygon() for that separate
|
||||
mechanism). A sharp-cornered rectangle converts losslessly to a
|
||||
4-corner polygon -- its 4 corners already are exactly what a
|
||||
rectangle is. Anything else offered here (an ellipse/arc, or a
|
||||
rectangle with rounded corners) used to get the exact same
|
||||
treatment, which is wrong: an ellipse flattened to its bounding
|
||||
rect's 4 corners doesn't resemble an ellipse at all, and rounded
|
||||
corners would be silently squared off. Both instead become a
|
||||
genuine Bezier Path, built from the two verified helper functions
|
||||
below (numerically confirmed against Qt's own arcTo()/addRoundedRect()
|
||||
output -- worst-case error a small fraction of a unit on shapes
|
||||
roughly 100-250 units across, i.e. visually indistinguishable, not
|
||||
merely "close enough to eyeball").
|
||||
*/
|
||||
void QetShapeItem::convertToPathOrPolygon()
|
||||
{
|
||||
if (m_shapeType != Rectangle && m_shapeType != Ellipse)
|
||||
return;
|
||||
|
||||
const bool hasRoundedCorners = (m_shapeType == Rectangle) && (m_xRadius > 0 || m_yRadius > 0);
|
||||
// Captured before m_shapeType changes below -- name() reports
|
||||
// whatever the CURRENT type is, and by the time the undo text is
|
||||
// built further down, m_shapeType has already become Polygon or
|
||||
// Path, so calling name() at that point would describe the shape's
|
||||
// new type, not what it actually was before conversion ("Convertir
|
||||
// une polyligne en polyligne" instead of "Convertir un rectangle en
|
||||
// polyligne").
|
||||
const QString originalName = name();
|
||||
const QDomElement before = snapshotXml();
|
||||
|
||||
QPolygonF corners;
|
||||
const QRectF r = localRect();
|
||||
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
||||
|
||||
prepareGeometryChange();
|
||||
m_shapeType = Polygon;
|
||||
m_polygon = corners;
|
||||
m_closed = true;
|
||||
|
||||
if (m_shapeType == Rectangle && !hasRoundedCorners)
|
||||
{
|
||||
QPolygonF corners;
|
||||
const QRectF r = localRect();
|
||||
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
||||
m_shapeType = Polygon;
|
||||
m_polygon = corners;
|
||||
m_closed = true;
|
||||
}
|
||||
else if (m_shapeType == Rectangle) // rounded corners
|
||||
{
|
||||
m_nodes = bezierNodesForRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
|
||||
m_shapeType = Path;
|
||||
m_closed = true;
|
||||
}
|
||||
else // Ellipse, full or arc
|
||||
{
|
||||
const QRectF r(m_P1, m_P2);
|
||||
if (isFullEllipse())
|
||||
{
|
||||
m_nodes = bezierNodesForArc(r, 0, 360);
|
||||
m_closed = true;
|
||||
}
|
||||
else if (m_arcClosure == Pie)
|
||||
{
|
||||
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
|
||||
PathNode centerNode;
|
||||
centerNode.anchor = r.center();
|
||||
m_nodes.prepend(centerNode);
|
||||
m_closed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Chord leaves the wrap-around segment's handles unset, which
|
||||
// falls through to QPainterPath::closeSubpath()'s own implicit
|
||||
// straight line -- exactly the chord behaviour -- when m_closed
|
||||
// is true; NoClosure is the same node list, just left open.
|
||||
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
|
||||
m_closed = (m_arcClosure == Chord);
|
||||
}
|
||||
m_shapeType = Path;
|
||||
}
|
||||
|
||||
const QDomElement after = snapshotXml();
|
||||
|
||||
if (diagram())
|
||||
{
|
||||
auto *undo = new PromoteShapeCommand(this, before, after);
|
||||
undo->setText(tr("Convertir %1 en polyligne").arg(name()));
|
||||
undo->setText(m_shapeType == Path
|
||||
? tr("Convertir %1 en courbe de Bézier").arg(originalName)
|
||||
: tr("Convertir %1 en polyligne").arg(originalName));
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
rebuildHandles();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetShapeItem::bezierNodesForArc
|
||||
Bezier approximation of an elliptical arc, split into <=90-degree
|
||||
segments (the standard cap for this technique to stay visually
|
||||
exact -- a single segment starts drifting noticeably past that).
|
||||
Each segment's handle length is (4/3)*tan(segment_span/4), the
|
||||
general-angle form of the well-known ~0.5523 "kappa" constant for
|
||||
an exact quarter circle. A full 360-degree span is a special case:
|
||||
it produces exactly `segments` nodes rather than `segments+1`
|
||||
(the very last one would just be a duplicate of the first at the
|
||||
same anchor point) and wires the wrap-around segment's own handles
|
||||
explicitly, so the loop closes as a continuous curve rather than
|
||||
the straight "chord" line a generic closed-path wrap-around would
|
||||
otherwise fall back to.
|
||||
@param rect the ellipse's bounding rect
|
||||
@param startAngleDeg arc start angle, matching QPainterPath::arcTo's own convention
|
||||
@param spanAngleDeg arc angular span, same convention (may be negative)
|
||||
@return nodes forming the arc; caller sets Path/m_closed and prepends
|
||||
a centre node itself for a Pie-style closure
|
||||
*/
|
||||
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg)
|
||||
{
|
||||
const bool fullLoop = qFuzzyCompare(qAbs(spanAngleDeg), 360.0);
|
||||
const qreal cx = rect.center().x(), cy = rect.center().y();
|
||||
const qreal rx = rect.width() / 2.0, ry = rect.height() / 2.0;
|
||||
|
||||
auto pointAt = [&](qreal angleDeg) {
|
||||
const qreal a = qDegreesToRadians(angleDeg);
|
||||
return QPointF(cx + rx * qCos(a), cy - ry * qSin(a));
|
||||
};
|
||||
auto tangentAt = [&](qreal angleDeg) {
|
||||
const qreal a = qDegreesToRadians(angleDeg);
|
||||
return QPointF(-rx * qSin(a), -ry * qCos(a));
|
||||
};
|
||||
|
||||
const int segments = qMax(1, int(qCeil(qAbs(spanAngleDeg) / 90.0)));
|
||||
const qreal segSpan = spanAngleDeg / segments;
|
||||
const qreal handleLen = (4.0 / 3.0) * qTan(qDegreesToRadians(qAbs(segSpan)) / 4.0);
|
||||
|
||||
const int nodeCount = fullLoop ? segments : segments + 1;
|
||||
QVector<PathNode> nodes(nodeCount);
|
||||
for (int i = 0; i < nodeCount; ++i)
|
||||
{
|
||||
nodes[i].anchor = pointAt(startAngleDeg + i * segSpan);
|
||||
nodes[i].kind = NodeKind::Smooth;
|
||||
}
|
||||
for (int i = 0; i < nodeCount; ++i)
|
||||
{
|
||||
const QPointF tangent = tangentAt(startAngleDeg + i * segSpan);
|
||||
if (fullLoop || i < nodeCount - 1)
|
||||
nodes[i].outHandle = tangent * handleLen;
|
||||
if (fullLoop || i > 0)
|
||||
nodes[i % nodeCount].inHandle = -tangent * handleLen;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetShapeItem::bezierNodesForRoundedRect
|
||||
Bezier equivalent of QPainterPath::addRoundedRect(): 8 anchors (each
|
||||
edge's two ends), straight lines along the 4 edges, and a Bezier
|
||||
quarter-turn at each of the 4 corners using the same handle-length
|
||||
formula as bezierNodesForArc() (with segSpan fixed at 90 degrees,
|
||||
since a rounded rect's corners always are). xRadius/yRadius are
|
||||
clamped to at most half the rect's own width/height, matching how
|
||||
QPainterPath::addRoundedRect() itself behaves for an
|
||||
over-large radius.
|
||||
@param rect the rectangle's own corner points, as a QRectF
|
||||
@param xRadius corner radius along the x axis
|
||||
@param yRadius corner radius along the y axis
|
||||
@return 8 nodes forming the closed rounded-rectangle outline
|
||||
*/
|
||||
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius)
|
||||
{
|
||||
const QRectF r = rect.normalized();
|
||||
const qreal rx = qBound(0.0, xRadius, r.width() / 2.0);
|
||||
const qreal ry = qBound(0.0, yRadius, r.height() / 2.0);
|
||||
const qreal kx = rx * 0.5522847498;
|
||||
const qreal ky = ry * 0.5522847498;
|
||||
|
||||
const QVector<QPointF> anchors = {
|
||||
QPointF(r.left() + rx, r.top()),
|
||||
QPointF(r.right() - rx, r.top()),
|
||||
QPointF(r.right(), r.top() + ry),
|
||||
QPointF(r.right(), r.bottom() - ry),
|
||||
QPointF(r.right() - rx, r.bottom()),
|
||||
QPointF(r.left() + rx, r.bottom()),
|
||||
QPointF(r.left(), r.bottom() - ry),
|
||||
QPointF(r.left(), r.top() + ry),
|
||||
};
|
||||
|
||||
QVector<PathNode> nodes;
|
||||
nodes.reserve(8);
|
||||
for (const QPointF &p : anchors)
|
||||
{
|
||||
PathNode n;
|
||||
n.anchor = p;
|
||||
n.kind = NodeKind::Smooth;
|
||||
nodes.append(n);
|
||||
}
|
||||
|
||||
nodes[1].outHandle = QPointF(kx, 0); nodes[2].inHandle = QPointF(0, -ky);
|
||||
nodes[3].outHandle = QPointF(0, ky); nodes[4].inHandle = QPointF(kx, 0);
|
||||
nodes[5].outHandle = QPointF(-kx, 0); nodes[6].inHandle = QPointF(0, ky);
|
||||
nodes[7].outHandle = QPointF(0, -ky); nodes[0].inHandle = QPointF(-kx, 0);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetShapeItem::mirror
|
||||
Flips the shape around its own current pivot -- horizontal negates
|
||||
@@ -2477,6 +2671,8 @@ void QetShapeItem::handlerMousePressEvent(int handlerIndex)
|
||||
m_old_polygon = m_polygon;
|
||||
m_old_xRadius = m_xRadius;
|
||||
m_old_yRadius = m_yRadius;
|
||||
m_old_startAngle = m_startAngle;
|
||||
m_old_endAngle = m_endAngle;
|
||||
m_old_transform = m_transform;
|
||||
m_old_pos = pos();
|
||||
m_old_nodes = m_nodes;
|
||||
@@ -2555,11 +2751,16 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "rect", QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized());
|
||||
}
|
||||
if (undo)
|
||||
undo->setText(tr("Redimensionner %1").arg(name()));
|
||||
break;
|
||||
|
||||
case HandleRole::Rotate:
|
||||
if (!qFuzzyCompare(m_transform.rotation, m_old_transform.rotation))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "rotation", m_old_transform.rotation, m_transform.rotation);
|
||||
undo->setText(tr("Faire pivoter %1").arg(name()));
|
||||
}
|
||||
break;
|
||||
|
||||
case HandleRole::SkewEdge:
|
||||
@@ -2567,6 +2768,8 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
undo = new QPropertyUndoCommand(this, "skewX", m_old_transform.skewX, m_transform.skewX);
|
||||
else if (!qFuzzyCompare(m_transform.skewY, m_old_transform.skewY))
|
||||
undo = new QPropertyUndoCommand(this, "skewY", m_old_transform.skewY, m_transform.skewY);
|
||||
if (undo)
|
||||
undo->setText(tr("Incliner %1").arg(name()));
|
||||
break;
|
||||
|
||||
case HandleRole::Pivot:
|
||||
@@ -2583,20 +2786,37 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius);
|
||||
new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo);
|
||||
undo->setText(tr("Arrondir les coins d'%1").arg(name()));
|
||||
}
|
||||
break;
|
||||
|
||||
case HandleRole::ArcEndpoint:
|
||||
// startAngle/endAngle changes are cosmetic-cost enough (and
|
||||
// re-derived from each other on snap-to-full-ellipse) that
|
||||
// they are intentionally not wrapped in undo here yet -- flag
|
||||
// for a follow-up once ArcEndpoint dragging ships in the UI.
|
||||
// The snap-to-full-ellipse behaviour in setStartAngle()/
|
||||
// setEndAngle() (see anglesGeometricallyAdjacent()) can
|
||||
// reset BOTH angles at once even though only one endpoint
|
||||
// was actually dragged, so both are checked here regardless
|
||||
// of which handle (slot 0 or 1) triggered this -- the same
|
||||
// reasoning as CornerRadius checking both xRadius and
|
||||
// yRadius above.
|
||||
if (!qFuzzyCompare(m_startAngle, m_old_startAngle))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "startAngle", m_old_startAngle, m_startAngle);
|
||||
if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
|
||||
new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle, undo);
|
||||
}
|
||||
else if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle);
|
||||
}
|
||||
if (undo)
|
||||
undo->setText(tr("Modifier l'angle d'un arc"));
|
||||
break;
|
||||
|
||||
case HandleRole::PathAnchor:
|
||||
if (m_shapeType == Polygon && m_polygon != m_old_polygon)
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon);
|
||||
undo->setText(tr("Modifier la forme d'%1").arg(name()));
|
||||
}
|
||||
else if (m_shapeType == Path && m_nodes != m_old_nodes)
|
||||
{
|
||||
@@ -2611,6 +2831,7 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
m_nodes = after;
|
||||
const QDomElement afterXml = snapshotXml();
|
||||
undo = new PromoteShapeCommand(this, before, afterXml);
|
||||
undo->setText(tr("Modifier la forme d'%1").arg(name()));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -2624,14 +2845,38 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
m_nodes = after;
|
||||
const QDomElement afterXml = snapshotXml();
|
||||
undo = new PromoteShapeCommand(this, before, afterXml);
|
||||
undo->setText(tr("Modifier la courbure d'%1").arg(name()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (undo)
|
||||
{
|
||||
// Defensive fallback only -- every role above now sets its own,
|
||||
// distinct label directly (Resize/Rotate/SkewEdge/CornerRadius/
|
||||
// PathAnchor/PathControlIn/PathControlOut used to all fall
|
||||
// through to this same generic text, making the undo list
|
||||
// unable to tell completely different edits apart); this only
|
||||
// still matters if some future role is ever added without
|
||||
// setting one of its own.
|
||||
if (undo->text().isEmpty())
|
||||
undo->setText(tr("Modifier %1").arg(name()));
|
||||
|
||||
// Every push here is one complete, finished gesture (press,
|
||||
// drag, release) -- never a continuation of an earlier one, the
|
||||
// way ArcEditor's slider deliberately pushes many commands
|
||||
// during a single ongoing drag and wants them to merge.
|
||||
// QPropertyUndoCommand::mergeWith() already treats any command
|
||||
// with children as never mergeable; a dummy child guarantees
|
||||
// that here regardless of which role produced undo. Without
|
||||
// this, a single-property change (Rotate and SkewEdge always
|
||||
// are; Resize and ArcEndpoint sometimes are, when only one of
|
||||
// their two properties actually changed) would silently
|
||||
// coalesce into whatever identically-labelled edit came right
|
||||
// before it -- even after a deselect/reselect proved they were
|
||||
// two separate actions, since the shared label is otherwise
|
||||
// indistinguishable from a genuine continuation.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,9 @@ class QetShapeItem : public QetGraphicsItem
|
||||
void repositionHandles(); // moves existing handler items -- safe to call every frame of a live drag
|
||||
void insertPoint();
|
||||
void removePoint();
|
||||
void convertToPathExplicitly(); // context-menu action; see promoteRectangleOrEllipseToPolygon()
|
||||
void convertToPathOrPolygon(); // context-menu action; see promoteRectangleOrEllipseToPolygon() for the separate Alt+drag mechanism
|
||||
static QVector<PathNode> bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg);
|
||||
static QVector<PathNode> bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius);
|
||||
void mirror(bool horizontal); // context-menu action: flips scaleFactorX (horizontal) or scaleFactorY (vertical) around the current pivot
|
||||
void setNodeKind(int nodeIndex, NodeKind kind); // context-menu action on a Path node
|
||||
|
||||
@@ -312,7 +314,9 @@ class QetShapeItem : public QetGraphicsItem
|
||||
qreal m_xRadius = 0,
|
||||
m_yRadius = 0,
|
||||
m_old_xRadius,
|
||||
m_old_yRadius;
|
||||
m_old_yRadius,
|
||||
m_old_startAngle,
|
||||
m_old_endAngle;
|
||||
|
||||
ShapeTransform m_transform;
|
||||
ShapeTransform m_old_transform;
|
||||
|
||||
@@ -65,6 +65,7 @@ namespace QET {
|
||||
QIcon EditCopy;
|
||||
QIcon EditCut;
|
||||
QIcon EditDelete;
|
||||
QIcon EditOpacity;
|
||||
QIcon EditPaste;
|
||||
QIcon EditRedo;
|
||||
QIcon EditRename;
|
||||
@@ -86,6 +87,7 @@ namespace QET {
|
||||
QIcon ElementEdit;
|
||||
QIcon ElementNew;
|
||||
QIcon ElementSmall;
|
||||
QIcon EllipseToBezier;
|
||||
QIcon EndLineCircle;
|
||||
QIcon EndLineDiamond;
|
||||
QIcon EndLineNone;
|
||||
@@ -115,6 +117,8 @@ namespace QET {
|
||||
QIcon Hide;
|
||||
QIcon Home;
|
||||
QIcon HotSpot;
|
||||
QIcon ImageFlipHorizontal;
|
||||
QIcon ImageFlipVertical;
|
||||
QIcon InsertImage;
|
||||
QIcon Lower;
|
||||
QIcon IC_MoveFile;
|
||||
@@ -156,6 +160,8 @@ namespace QET {
|
||||
QIcon QETOxygenLogo;
|
||||
QIcon QtLogo;
|
||||
QIcon Raise;
|
||||
QIcon RectToBezier;
|
||||
QIcon RectToPolyline;
|
||||
QIcon Remove;
|
||||
QIcon Restore;
|
||||
QIcon RunDxf;
|
||||
@@ -169,6 +175,7 @@ namespace QET {
|
||||
QIcon TitleBlock;
|
||||
QIcon TitleBlockBottom;
|
||||
QIcon TitleBlockRight;
|
||||
QIcon TransformCrop;
|
||||
QIcon TransformRotate;
|
||||
QIcon UserInformations;
|
||||
QIcon ViewFitWidth;
|
||||
@@ -470,6 +477,7 @@ void QET::Icons::initIcons()
|
||||
EditCut .addFile(":/ico/22x22/edit-cut.png");
|
||||
EditDelete .addFile(":/ico/16x16/edit-delete.png");
|
||||
EditDelete .addFile(":/ico/22x22/edit-delete.png");
|
||||
EditOpacity .addFile(":/ico/breeze-icons/scalable/apps/hidef/edit-opacity.svg");
|
||||
EditPaste .addFile(":/ico/22x22/edit-paste.png");
|
||||
EditPaste .addFile(":/ico/16x16/edit-paste.png");
|
||||
if (rtl) {
|
||||
@@ -518,6 +526,7 @@ void QET::Icons::initIcons()
|
||||
ElementNew .addFile(":/ico/16x16/element-new.png");
|
||||
ElementNew .addFile(":/ico/22x22/element-new.png");
|
||||
ElementSmall .addFile(":/ico/16x16/element.png");
|
||||
EllipseToBezier .addFile(":/ico/generated/ellipse-to-bezier.svg");
|
||||
EndLineCircle .addFile(":/ico/16x16/endline-circle.png");
|
||||
EndLineDiamond .addFile(":/ico/16x16/endline-diamond.png");
|
||||
EndLineNone .addFile(":/ico/16x16/endline-none.png");
|
||||
@@ -557,6 +566,8 @@ void QET::Icons::initIcons()
|
||||
Home .addFile(":/ico/16x16/go-home.png");
|
||||
Home .addFile(":/ico/22x22/go-home.png");
|
||||
HotSpot .addFile(":/ico/22x22/hotspot.png");
|
||||
ImageFlipHorizontal .addFile(":/ico/breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg");
|
||||
ImageFlipVertical .addFile(":/ico/breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg");
|
||||
InsertImage .addFile(":/ico/22x22/insert-image.png");
|
||||
Lower .addFile(":/ico/22x22/lower.png");
|
||||
IC_MoveFile .addFile(":/ico/16x16/item-move.png");
|
||||
@@ -615,6 +626,8 @@ void QET::Icons::initIcons()
|
||||
QETVideo .addFile(":/ico/16x16/kdenlive-show-video.png");
|
||||
QtLogo .addFile(":/ico/16x16/qt.png");
|
||||
Raise .addFile(":/ico/22x22/raise.png");
|
||||
RectToBezier .addFile(":/ico/generated/rect-to-bezier.svg");
|
||||
RectToPolyline .addFile(":/ico/generated/rect-to-polyline.svg");
|
||||
Remove .addFile(":/ico/16x16/list-remove.png");
|
||||
Remove .addFile(":/ico/22x22/list-remove.png");
|
||||
resize_image .addFile(":/ico/22x22/transform-scale.png");
|
||||
@@ -633,6 +646,7 @@ void QET::Icons::initIcons()
|
||||
TitleBlock .addFile(":/ico/22x22/label.png");
|
||||
TitleBlockBottom .addFile(":/ico/22x22/titleblock-bottom.png");
|
||||
TitleBlockRight .addFile(":/ico/22x22/titleblock-right.png");
|
||||
TransformCrop .addFile(":/ico/breeze-icons/scalable/apps/hidef/transform-crop.svg");
|
||||
TransformRotate .addFile(":/ico/16x16/transform-rotate.png");
|
||||
UserInformations .addFile(":/ico/16x16/preferences-desktop-user.png");
|
||||
UserInformations .addFile(":/ico/22x22/preferences-desktop-user.png");
|
||||
|
||||
@@ -72,6 +72,7 @@ namespace QET {
|
||||
extern QIcon EditCopy;
|
||||
extern QIcon EditCut;
|
||||
extern QIcon EditDelete;
|
||||
extern QIcon EditOpacity;
|
||||
extern QIcon EditPaste;
|
||||
extern QIcon EditRedo;
|
||||
extern QIcon EditRename;
|
||||
@@ -92,6 +93,7 @@ namespace QET {
|
||||
extern QIcon ElementDelete;
|
||||
extern QIcon ElementEdit;
|
||||
extern QIcon ElementNew;
|
||||
extern QIcon EllipseToBezier;
|
||||
extern QIcon EndLineCircle;
|
||||
extern QIcon EndLineDiamond;
|
||||
extern QIcon EndLineNone;
|
||||
@@ -121,6 +123,8 @@ namespace QET {
|
||||
extern QIcon Hide;
|
||||
extern QIcon Home;
|
||||
extern QIcon HotSpot;
|
||||
extern QIcon ImageFlipHorizontal;
|
||||
extern QIcon ImageFlipVertical;
|
||||
extern QIcon InsertImage;
|
||||
extern QIcon Lower;
|
||||
extern QIcon IC_MoveFile;
|
||||
@@ -164,6 +168,8 @@ namespace QET {
|
||||
extern QIcon QETOxygenLogo;
|
||||
extern QIcon QtLogo;
|
||||
extern QIcon Raise;
|
||||
extern QIcon RectToBezier;
|
||||
extern QIcon RectToPolyline;
|
||||
extern QIcon Remove;
|
||||
extern QIcon Restore;
|
||||
extern QIcon RunDxf;
|
||||
@@ -177,6 +183,7 @@ namespace QET {
|
||||
extern QIcon TitleBlock;
|
||||
extern QIcon TitleBlockBottom;
|
||||
extern QIcon TitleBlockRight;
|
||||
extern QIcon TransformCrop;
|
||||
extern QIcon TransformRotate;
|
||||
extern QIcon UserInformations;
|
||||
extern QIcon ViewFitWidth;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QSlider>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QVector>
|
||||
|
||||
@@ -48,10 +49,10 @@ ClickableImageLabel::ClickableImageLabel(const QImage &sourceImage, QWidget *par
|
||||
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_displayImage = (m_displayScale < 1.0)
|
||||
? m_source.scaled(m_source.size() * m_displayScale, Qt::KeepAspectRatio, Qt::SmoothTransformation)
|
||||
: m_source;
|
||||
setPixmap(QPixmap::fromImage(displayImage));
|
||||
setPixmap(QPixmap::fromImage(m_displayImage));
|
||||
setCursor(Qt::CrossCursor);
|
||||
setToolTip(tr("Cliquez pour choisir une couleur"));
|
||||
}
|
||||
@@ -91,27 +92,28 @@ void ClickableImageLabel::mousePressEvent(QMouseEvent *event)
|
||||
@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) :
|
||||
ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap,
|
||||
const QList<PickedColor> &existingColors, QWidget *parent) :
|
||||
QDialog(parent),
|
||||
m_sourceImage(basePixmap.toImage()),
|
||||
m_pickedColors(existingColors),
|
||||
m_tolerance(existingTolerance)
|
||||
m_pickedColors(existingColors)
|
||||
{
|
||||
setWindowTitle(tr("Couleur transparente"));
|
||||
|
||||
if (!m_pickedColors.isEmpty())
|
||||
m_lastToleranceUsed = m_pickedColors.last().tolerance;
|
||||
|
||||
m_sourceLabel = new ClickableImageLabel(m_sourceImage, this);
|
||||
m_previewSourceImage = m_sourceLabel->displayImage();
|
||||
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);
|
||||
m_hintLabel->setWordWrap(true);
|
||||
|
||||
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
|
||||
m_startedWithColors = !m_pickedColors.isEmpty();
|
||||
updateOkEnabled();
|
||||
|
||||
auto *grid = new QGridLayout;
|
||||
grid->addWidget(new QLabel(tr("Image source")), 0, 0);
|
||||
@@ -119,29 +121,19 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
|
||||
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);
|
||||
// One row per picked colour, appended/removed as rebuildSwatches()
|
||||
// runs -- starts empty either way, populated by the very first
|
||||
// rebuildSwatches() call below (including from existingColors, if
|
||||
// any were passed in).
|
||||
m_swatchesLayout = new QVBoxLayout;
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->addLayout(grid);
|
||||
mainLayout->addLayout(colorRow);
|
||||
mainLayout->addLayout(toleranceRow);
|
||||
mainLayout->addWidget(m_hintLabel);
|
||||
mainLayout->addLayout(m_swatchesLayout);
|
||||
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);
|
||||
|
||||
@@ -156,42 +148,88 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
|
||||
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.
|
||||
row for it. The new colour is seeded with whatever tolerance was
|
||||
last used (not some fixed default), so successive picks in one
|
||||
session feel consistent.
|
||||
@param color the colour sampled from the source image
|
||||
*/
|
||||
void ImageTransparentColorDialog::onColorPicked(const QColor &color)
|
||||
{
|
||||
if (m_pickedColors.contains(color))
|
||||
return;
|
||||
for (const PickedColor &existing : std::as_const(m_pickedColors))
|
||||
if (existing.color == color)
|
||||
return;
|
||||
|
||||
m_pickedColors.append(color);
|
||||
m_okButton->setEnabled(true);
|
||||
m_pickedColors.append({color, m_lastToleranceUsed});
|
||||
updateOkEnabled();
|
||||
rebuildSwatches();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageTransparentColorDialog::setToleranceForIndex
|
||||
Updates one colour's own tolerance in place -- deliberately doesn't
|
||||
call rebuildSwatches(): only a number changed, not which rows exist
|
||||
or what order they're in, so recreating every row's widgets (and,
|
||||
worse, the very slider currently being dragged) on each tick would
|
||||
be both wasteful and liable to interrupt the drag itself.
|
||||
@param index which entry in m_pickedColors changed
|
||||
@param value its new tolerance, 0-100
|
||||
*/
|
||||
void ImageTransparentColorDialog::setToleranceForIndex(int index, int value)
|
||||
{
|
||||
if (index < 0 || index >= m_pickedColors.size())
|
||||
return;
|
||||
m_pickedColors[index].tolerance = value;
|
||||
m_lastToleranceUsed = value;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageTransparentColorDialog::updateOkEnabled
|
||||
OK is disabled only for the one case where accepting would genuinely
|
||||
do nothing: a dialog that started with no colours and still has
|
||||
none. Removing every colour from a dialog that DID start with some
|
||||
is a different, meaningful action -- clearing all transparency back
|
||||
to a plain, opaque image -- and has to stay confirmable, not silently
|
||||
blocked the same way. Re-run after every add or remove, since either
|
||||
can cross the "any colours at all, ever" threshold in either
|
||||
direction.
|
||||
*/
|
||||
void ImageTransparentColorDialog::updateOkEnabled()
|
||||
{
|
||||
m_okButton->setEnabled(!m_pickedColors.isEmpty() || m_startedWithColors);
|
||||
}
|
||||
|
||||
/**
|
||||
@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
|
||||
@param index position in m_pickedColors to remove
|
||||
*/
|
||||
void ImageTransparentColorDialog::removeColor(const QColor &color)
|
||||
void ImageTransparentColorDialog::removeColor(int index)
|
||||
{
|
||||
m_pickedColors.removeAll(color);
|
||||
m_okButton->setEnabled(!m_pickedColors.isEmpty());
|
||||
if (index < 0 || index >= m_pickedColors.size())
|
||||
return;
|
||||
|
||||
m_pickedColors.removeAt(index);
|
||||
updateOkEnabled();
|
||||
rebuildSwatches();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageTransparentColorDialog::rebuildSwatches
|
||||
Rebuilds the row of picked-colour swatches from scratch against the
|
||||
Rebuilds the list of picked-colour rows from scratch against the
|
||||
current m_pickedColors -- simpler and safer than trying to
|
||||
incrementally add/remove individual widgets in sync with the list,
|
||||
incrementally add/remove individual rows 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.
|
||||
large enough for a full rebuild to be a real cost. Each row is a
|
||||
colour swatch (a plain, round, non-interactive indicator -- there's
|
||||
nothing left to click it FOR, now that each row carries its own
|
||||
slider instead of one shared slider needing a row selected first),
|
||||
that row's own tolerance slider, and a small "x" button that
|
||||
removes it.
|
||||
*/
|
||||
void ImageTransparentColorDialog::rebuildSwatches()
|
||||
{
|
||||
@@ -202,88 +240,109 @@ void ImageTransparentColorDialog::rebuildSwatches()
|
||||
delete item;
|
||||
}
|
||||
|
||||
for (const QColor &color : std::as_const(m_pickedColors))
|
||||
for (int i = 0; i < m_pickedColors.size(); ++i)
|
||||
{
|
||||
auto *swatch = new QPushButton(this);
|
||||
const PickedColor &pc = m_pickedColors.at(i);
|
||||
|
||||
auto *row = new QWidget(this);
|
||||
auto *rowLayout = new QHBoxLayout(row);
|
||||
rowLayout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
auto *swatch = new QLabel(row);
|
||||
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);
|
||||
swatch->setStyleSheet(QStringLiteral(
|
||||
"background-color: rgb(%1,%2,%3); border: 1px solid palette(mid); border-radius: 12px;")
|
||||
.arg(pc.color.red()).arg(pc.color.green()).arg(pc.color.blue()));
|
||||
swatch->setToolTip(tr("rgb(%1, %2, %3)").arg(pc.color.red()).arg(pc.color.green()).arg(pc.color.blue()));
|
||||
|
||||
auto *slider = new QSlider(Qt::Horizontal, row);
|
||||
slider->setRange(0, 100);
|
||||
slider->setValue(pc.tolerance);
|
||||
slider->setToolTip(tr("Tolérance pour cette couleur"));
|
||||
connect(slider, &QSlider::valueChanged, this, [this, i](int value) { setToleranceForIndex(i, value); });
|
||||
|
||||
auto *removeButton = new QToolButton(row);
|
||||
removeButton->setText(QStringLiteral("×"));
|
||||
removeButton->setToolTip(tr("Retirer cette couleur"));
|
||||
connect(removeButton, &QToolButton::clicked, this, [this, i]() { removeColor(i); });
|
||||
|
||||
rowLayout->addWidget(swatch);
|
||||
rowLayout->addWidget(slider);
|
||||
rowLayout->addWidget(removeButton);
|
||||
m_swatchesLayout->addWidget(row);
|
||||
}
|
||||
|
||||
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();
|
||||
: tr("Cliquez sur l'image pour ajouter une couleur. Ajustez la tolérance de chaque couleur avec son curseur, ou cliquez sur × pour la retirer."));
|
||||
}
|
||||
|
||||
/**
|
||||
@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.
|
||||
of picked colours, each at its own tolerance. Runs against
|
||||
m_previewSourceImage (the same downsampled copy ClickableImageLabel
|
||||
already computed for its own display, not the full-resolution
|
||||
m_sourceImage) -- this fires on every tolerance slider tick, not
|
||||
just on release, and a full-resolution O(width*height*colours) pass
|
||||
per tick was visibly laggy on a large source image. resultPixmap(),
|
||||
below, still computes the final, committed result at full
|
||||
resolution -- only this live preview is downsampled. Always
|
||||
re-evaluates every picked colour from scratch against
|
||||
m_previewSourceImage, not any already-keyed result, so adjusting a
|
||||
tolerance or adding/removing a colour never compounds successive
|
||||
passes.
|
||||
*/
|
||||
void ImageTransparentColorDialog::updatePreview()
|
||||
{
|
||||
const QImage keyed = applyColorKey(m_sourceImage, m_pickedColors, m_tolerance);
|
||||
const QImage keyed = applyColorKey(m_previewSourceImage, m_pickedColors);
|
||||
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).
|
||||
@return the colour-keyed pixmap against every picked colour, each at
|
||||
its own tolerance, 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));
|
||||
return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors));
|
||||
}
|
||||
|
||||
/**
|
||||
@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.
|
||||
pixel within a colour's own `tolerance` (0-100, mapped onto the
|
||||
maximum possible RGB distance) of that colour becomes fully
|
||||
transparent, everything else keeps its existing alpha untouched --
|
||||
each of keyColors is checked against its own threshold, not one
|
||||
shared across all of them, since a background colour and a border
|
||||
colour rarely call for the same looseness of match. 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)
|
||||
@param keyColors the colours (each with its own tolerance) to make transparent
|
||||
@return the resulting image, always in Format_ARGB32
|
||||
*/
|
||||
QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList<QColor> &keyColors, int tolerance)
|
||||
QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList<PickedColor> &keyColors)
|
||||
{
|
||||
QImage result = source.convertToFormat(QImage::Format_ARGB32);
|
||||
if (keyColors.isEmpty())
|
||||
return result;
|
||||
|
||||
QVector<QRgb> keys;
|
||||
struct KeyEntry { QRgb rgb; qint64 threshold; };
|
||||
QVector<KeyEntry> 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 (const PickedColor &pc : keyColors)
|
||||
{
|
||||
const qint64 threshold = qint64(pc.tolerance) * pc.tolerance * 3 * 255 * 255 / (100 * 100);
|
||||
keys.append({pc.color.rgb(), threshold});
|
||||
}
|
||||
|
||||
for (int y = 0; y < result.height(); ++y)
|
||||
{
|
||||
@@ -291,11 +350,11 @@ QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QL
|
||||
for (int x = 0; x < result.width(); ++x)
|
||||
{
|
||||
const QRgb px = line[x];
|
||||
for (const QRgb &key : keys)
|
||||
for (const KeyEntry &key : std::as_const(keys))
|
||||
{
|
||||
const int dr = qRed(px) - qRed(key), dg = qGreen(px) - qGreen(key), db = qBlue(px) - qBlue(key);
|
||||
const int dr = qRed(px) - qRed(key.rgb), dg = qGreen(px) - qGreen(key.rgb), db = qBlue(px) - qBlue(key.rgb);
|
||||
const qint64 distSq = qint64(dr) * dr + qint64(dg) * dg + qint64(db) * db;
|
||||
if (distSq <= threshold)
|
||||
if (distSq <= key.threshold)
|
||||
{
|
||||
line[x] = qRgba(qRed(px), qGreen(px), qBlue(px), 0);
|
||||
break;
|
||||
|
||||
@@ -24,9 +24,8 @@
|
||||
#include <QLabel>
|
||||
#include <QList>
|
||||
|
||||
class QSlider;
|
||||
class QVBoxLayout;
|
||||
class QPushButton;
|
||||
class QHBoxLayout;
|
||||
class QMouseEvent;
|
||||
|
||||
/**
|
||||
@@ -45,6 +44,17 @@ class ClickableImageLabel : public QLabel
|
||||
public:
|
||||
explicit ClickableImageLabel(const QImage &sourceImage, QWidget *parent = nullptr);
|
||||
|
||||
/// The same downsampled image already computed for display --
|
||||
/// reused as the live-preview source so a tolerance drag runs
|
||||
/// its per-tick colour-key pass against a small image instead
|
||||
/// of the full-resolution one, which on a large source (a
|
||||
/// scanned schematic background, several Mpx) made every
|
||||
/// intermediate slider tick visibly lag. resultPixmap() still
|
||||
/// computes the final, committed result from the true
|
||||
/// full-resolution source -- only the live preview is
|
||||
/// downsampled.
|
||||
QImage displayImage() const { return m_displayImage; }
|
||||
|
||||
signals:
|
||||
void colorPicked(const QColor &color);
|
||||
|
||||
@@ -53,6 +63,7 @@ class ClickableImageLabel : public QLabel
|
||||
|
||||
private:
|
||||
QImage m_source;
|
||||
QImage m_displayImage;
|
||||
qreal m_displayScale = 1.0;
|
||||
};
|
||||
|
||||
@@ -60,73 +71,90 @@ class ClickableImageLabel : public QLabel
|
||||
@brief The ImageTransparentColorDialog class
|
||||
Lets the user click directly on a preview of the image to sample one
|
||||
or more colors -- each click adds to the set rather than replacing
|
||||
the previous pick, shown as a row of removable swatches -- adjust a
|
||||
shared tolerance, and see a live checkerboard-backed preview of the
|
||||
result before committing. A self-contained modal dialog rather than
|
||||
a diagram-level "click the canvas to pick" interaction mode, since
|
||||
this needs neither undo-during-drag nor coexistence with other
|
||||
tools; it only ever needs a handful of clicks, evaluated against a
|
||||
pixmap the caller already has in hand.
|
||||
the previous pick -- each shown as its own row: a colour swatch, a
|
||||
slider for that colour's own tolerance right next to it, and a
|
||||
remove button. Each colour keeps its own tolerance rather than
|
||||
sharing one: a white background and a grey border rarely need the
|
||||
same looseness of match, and forcing one tolerance onto both meant
|
||||
either the background left ragged edges or the border ate into
|
||||
content near it. A live checkerboard-backed preview of the combined
|
||||
result updates as any slider moves. A self-contained modal dialog
|
||||
rather than a diagram-level "click the canvas to pick" interaction
|
||||
mode, since this needs neither undo-during-drag nor coexistence
|
||||
with other tools; it only ever needs a handful of clicks, evaluated
|
||||
against a pixmap the caller already has in hand.
|
||||
*/
|
||||
class ImageTransparentColorDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/// One picked colour and the tolerance it's individually keyed
|
||||
/// with -- the whole point of this being a struct rather than
|
||||
/// two parallel lists is that the two can never drift out of
|
||||
/// index alignment with each other.
|
||||
struct PickedColor
|
||||
{
|
||||
QColor color;
|
||||
int tolerance;
|
||||
};
|
||||
|
||||
/// @param basePixmap the pristine source to pick colours from --
|
||||
/// the caller's responsibility to pass the true original, not
|
||||
/// an already colour-keyed result, or previously-transparent
|
||||
/// areas would show as plain background rather than a pickable
|
||||
/// surface, and re-picking the same colour would be a no-op.
|
||||
/// @param existingColors colours already keyed out of basePixmap
|
||||
/// in a previous session, shown as swatches from the start
|
||||
/// rather than forcing them to be re-picked from scratch.
|
||||
/// @param existingTolerance the tolerance from that previous
|
||||
/// session, if any.
|
||||
explicit ImageTransparentColorDialog(const QPixmap &basePixmap, const QList<QColor> &existingColors = {},
|
||||
int existingTolerance = 10, QWidget *parent = nullptr);
|
||||
/// @param existingColors colours (with their individual
|
||||
/// tolerances) already keyed out of basePixmap in a previous
|
||||
/// session, shown as rows from the start rather than forcing
|
||||
/// them to be re-picked from scratch.
|
||||
explicit ImageTransparentColorDialog(const QPixmap &basePixmap,
|
||||
const QList<PickedColor> &existingColors = {}, QWidget *parent = nullptr);
|
||||
|
||||
/// The resulting pixmap: basePixmap unchanged if no colour is
|
||||
/// picked, colour-keyed against every picked colour otherwise.
|
||||
/// picked, colour-keyed against every picked colour (each at
|
||||
/// its own tolerance) otherwise.
|
||||
QPixmap resultPixmap() const;
|
||||
/// The final set of picked colours, for the caller to remember
|
||||
/// across dialog sessions -- may differ from existingColors if
|
||||
/// any were added or removed.
|
||||
QList<QColor> pickedColors() const { return m_pickedColors; }
|
||||
/// The final tolerance, for the same reason.
|
||||
int tolerance() const { return m_tolerance; }
|
||||
/// The final set of picked colours and their individual
|
||||
/// tolerances, for the caller to remember across dialog
|
||||
/// sessions -- may differ from existingColors if any were
|
||||
/// added, removed, or had their tolerance adjusted.
|
||||
QList<PickedColor> pickedColors() const { return m_pickedColors; }
|
||||
|
||||
/// Public so DiagramImageItem can re-derive its display pixmap
|
||||
/// directly (base + crop + these colours) without needing to
|
||||
/// re-open this dialog every time the crop region changes --
|
||||
/// 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.
|
||||
static QImage applyColorKey(const QImage &source, const QList<QColor> &keyColors, int tolerance);
|
||||
/// every pixel within a colour's own `tolerance` (0-100, mapped
|
||||
/// onto the maximum possible RGB distance) of that colour
|
||||
/// 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.
|
||||
static QImage applyColorKey(const QImage &source, const QList<PickedColor> &keyColors);
|
||||
|
||||
private slots:
|
||||
void onColorPicked(const QColor &color);
|
||||
void onToleranceChanged(int value);
|
||||
|
||||
private:
|
||||
void removeColor(const QColor &color);
|
||||
void setToleranceForIndex(int index, int value);
|
||||
void removeColor(int index);
|
||||
void rebuildSwatches();
|
||||
void updatePreview();
|
||||
void updateOkEnabled();
|
||||
static QPixmap onCheckerboard(const QImage &image);
|
||||
|
||||
QImage m_sourceImage;
|
||||
QList<QColor> m_pickedColors;
|
||||
int m_tolerance = 10;
|
||||
QImage m_sourceImage;
|
||||
QImage m_previewSourceImage; // downsampled -- see ClickableImageLabel::displayImage()'s comment for why
|
||||
QList<PickedColor> m_pickedColors;
|
||||
bool m_startedWithColors = false; // whether existingColors was non-empty -- see updateOkEnabled()'s comment for why this matters
|
||||
int m_lastToleranceUsed = 10; // seeds a newly-picked colour's own tolerance, so successive picks in one session feel consistent rather than each resetting to some fixed default
|
||||
|
||||
ClickableImageLabel *m_sourceLabel;
|
||||
QLabel *m_previewLabel;
|
||||
QHBoxLayout *m_swatchesLayout;
|
||||
QVBoxLayout *m_swatchesLayout; // one row per picked colour, stacked vertically -- each row now carries its own slider, too wide to lay out side by side the way plain swatches once were
|
||||
QLabel *m_hintLabel;
|
||||
QSlider *m_toleranceSlider;
|
||||
QPushButton *m_okButton;
|
||||
};
|
||||
|
||||
|
||||
@@ -77,3 +77,11 @@ target_link_libraries(
|
||||
${KF_PRIVATE_LIBRARIES}
|
||||
${QET_PRIVATE_LIBRARIES})
|
||||
|
||||
# diagramsortkeys.h is a header-only helper (no QET link deps needed
|
||||
# beyond Qt itself), so this test builds independently of the rest of
|
||||
# the QET sources.
|
||||
add_executable(tst_diagramsortkeys tst_diagramsortkeys.cpp)
|
||||
add_test(NAME tst_diagramsortkeys COMMAND tst_diagramsortkeys)
|
||||
target_include_directories(tst_diagramsortkeys PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_diagramsortkeys PRIVATE Qt::Test)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "diagramsortkeys.h"
|
||||
|
||||
class tst_diagramsortkeys : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
// positionKey() must sort the same way the underlying coordinates do,
|
||||
// including across differing integer-part digit widths and across the
|
||||
// negative/positive boundary. A previous implementation formatted
|
||||
// coordinates with plain "%.4f" and compared the resulting strings
|
||||
// directly, which sorted "15.0000" before "5.0000".
|
||||
void sortsLikeNumbers_data()
|
||||
{
|
||||
QTest::addColumn<QPointF>("smaller");
|
||||
QTest::addColumn<QPointF>("larger");
|
||||
|
||||
QTest::newRow("single vs double digit") << QPointF(5.0, 0.0) << QPointF(15.0, 0.0);
|
||||
QTest::newRow("double vs triple digit") << QPointF(0.0, 99.0) << QPointF(0.0, 100.0);
|
||||
QTest::newRow("negative vs negative") << QPointF(-15.0, 0.0) << QPointF(-5.0, 0.0);
|
||||
QTest::newRow("negative vs positive") << QPointF(-1.0, 0.0) << QPointF(1.0, 0.0);
|
||||
QTest::newRow("negative vs zero") << QPointF(0.0, -0.0001) << QPointF(0.0, 0.0);
|
||||
QTest::newRow("fractional precision") << QPointF(1.0001, 0.0) << QPointF(1.001, 0.0);
|
||||
}
|
||||
|
||||
void sortsLikeNumbers()
|
||||
{
|
||||
QFETCH(QPointF, smaller);
|
||||
QFETCH(QPointF, larger);
|
||||
|
||||
QVERIFY(DiagramSortKeys::positionKey(smaller) < DiagramSortKeys::positionKey(larger));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_APPLESS_MAIN(tst_diagramsortkeys)
|
||||
|
||||
#include "tst_diagramsortkeys.moc"
|
||||