Compare commits

...

15 Commits

Author SHA1 Message Date
Laurent Trinques 82026d8f7c Merge pull request #818 from qelectrotech/revert-804-feature/terminal-potential-grouping
Revert "Feature: Terminal potential grouping"
2026-09-07 15:47:08 +02:00
Laurent Trinques 1793dcb9a8 Revert "Feature: Terminal potential grouping" 2026-09-07 15:44:12 +02:00
Laurent Trinques cc7aaf3538 Merge pull request #804 from IBSYSLevi/feature/terminal-potential-grouping
Feature: Terminal potential grouping
2026-09-07 15:08:00 +02:00
Laurent Trinques a6285c7ef7 Merge pull request #815 from ispyisail/test/diagramsortkeys-unit-test
Add unit test locking in Diagram::toXml() coordinate sort order
2026-09-07 14:31:26 +02:00
Laurent Trinques f87381a910 Merge pull request #817 from arummler/feature-graphics-part-2
Fixes and embelishment for some of the graphics and image features
2026-09-07 14:29:24 +02:00
ispyisail bd5cff4211 Add a unit test locking in Diagram::toXml()'s coordinate sort order
Follow-up to the positionKey() fix merged directly in #779
(b2f4ef5d2): per review request, add a small regression test so this
class of bug (fixed-precision "%.4f" formatting compares out of
numeric order once the integer part's digit count differs) can't
silently reappear.

positionKey()/coordinateKey() move out of diagram.cpp's anonymous
namespace into a small header-only diagramsortkeys.h so the test can
link against the exact same code Diagram::toXml() uses, instead of
duplicating the algorithm. Behavior is unchanged.

tst_diagramsortkeys covers: single- vs double-digit, double- vs
triple-digit, negative-vs-negative, negative-vs-positive, and
negative-vs-zero coordinate pairs, plus sub-precision deltas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:54:18 +12:00
Andre Rummler 296f142036 Adding icons to graphs and image operations. 2026-09-07 10:44:30 +02:00
Andre Rummler 4d991d0419 Conversion of ellipses and rounded rectangles to bezier curves instead of polylines. 2026-09-07 10:44:30 +02:00
Andre Rummler a85bd6a49b Fixing undo operations of graphs and images. Changing undo names. 2026-09-07 10:44:30 +02:00
Andre Rummler ef8e41a5c3 Fix transparency export (not possible for jpeg). 2026-09-07 10:44:30 +02:00
Andre Rummler 6f80da66d5 Connected to previous image handling updates: images were not exported at all to dxf. In this
update instead of the image a placeholder box is drawn and a warning is given during export.

Current dxf version is extremely old and does not support any image embedding. Future possible
update is the introduction of a newer dxf version export option which would allow to embed a link to an external
image file.
2026-09-07 10:44:30 +02:00
Andre Rummler a065867502 Fix issue that made it impossible to remove all transparency colours defined in a previously opened dialog. On the last color the OK button was greyed out. 2026-09-07 10:44:29 +02:00
Levi Jetzer 1e1039dd32 Address review
1. Drop dead prepareGeometryChange()/update() calls in
setPotential(), split unrelated terminaleditor.h re-indent into its
own change

2. Removed: setPotential() had prepareGeometryChange()/update() left over from
the label-display mechanism that was dropped before this PR
2026-09-04 13:12:21 +02:00
IBSYSLevi e410896f5d Merge branch 'qelectrotech:master' into feature/terminal-potential-grouping 2026-09-02 20:12:14 +02:00
Levi Jetzer 10bf0411bb Add "Potentiel" field to the terminal editor (element editor)
New QLineEdit (m_potential_le) between Type and Nom: an optional,
symbol-author-chosen grouping identifier shared by terminals that
belong to the same physical terminal within a multi-terminal block.

Stored as TerminalData::m_potential (new field, persisted as the
"potential" XML attribute, empty by default).
Used by relatedPotentialTerminal() in terminal.cpp: when
potential_isolating is enabled on a Terminal-type element, terminals
sharing a non-empty, matching potential value now stay electrically
linked to each other instead of every terminal in the block being
isolated from every other one.

Tooltip on the field for explanation
2026-09-02 19:30:26 +02:00
25 changed files with 897 additions and 63 deletions
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

+7
View File
@@ -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>
@@ -128,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;
}
+2 -19
View File
@@ -40,30 +40,13 @@
#include "undocommand/addelementtextcommand.h"
#include "qetinformation.h"
#include "qetproject.h"
#include "diagramsortkeys.h"
#include <algorithm>
#include <cassert>
#include <math.h>
namespace {
/// 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.
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'));
}
QString positionKey(const QPointF &pos)
{
return coordinateKey(pos.x()) + QLatin1Char('|') + coordinateKey(pos.y());
}
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),
+53
View File
@@ -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
+38 -8
View File
@@ -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;
}
+12 -5
View File
@@ -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
{
+55
View File
@@ -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);
+13 -5
View File
@@ -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) {
+235 -3
View File
@@ -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
*/
@@ -1382,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());
@@ -1454,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);
}
@@ -1497,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);
}
@@ -1546,6 +1773,11 @@ void DiagramImageItem::setTransparentColor()
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);
}
@@ -24,6 +24,7 @@
#include <QColor>
#include <QList>
#include <QStyleOptionGraphicsItem>
#include <QVector>
class QDomElement;
@@ -96,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);
@@ -129,6 +143,10 @@ class DiagramImageItem : public QetGraphicsItem {
void setTransparentColor();
void crop();
void restoreAspectRatio();
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();
+263 -18
View File
@@ -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);
}
}
+6 -2
View File
@@ -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;
+14
View File
@@ -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");
+7
View File
@@ -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;
+20 -3
View File
@@ -112,7 +112,8 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
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);
@@ -159,7 +160,7 @@ void ImageTransparentColorDialog::onColorPicked(const QColor &color)
return;
m_pickedColors.append({color, m_lastToleranceUsed});
m_okButton->setEnabled(true);
updateOkEnabled();
rebuildSwatches();
updatePreview();
}
@@ -183,6 +184,22 @@ void ImageTransparentColorDialog::setToleranceForIndex(int index, int 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()
@@ -196,7 +213,7 @@ void ImageTransparentColorDialog::removeColor(int index)
return;
m_pickedColors.removeAt(index);
m_okButton->setEnabled(!m_pickedColors.isEmpty());
updateOkEnabled();
rebuildSwatches();
updatePreview();
}
+2
View File
@@ -142,11 +142,13 @@ class ImageTransparentColorDialog : public QDialog
void removeColor(int index);
void rebuildSwatches();
void updatePreview();
void updateOkEnabled();
static QPixmap onCheckerboard(const QImage &image);
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;
+8
View File
@@ -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)
+39
View File
@@ -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"