diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index 2bc2edcce..7fc68a769 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -318,6 +318,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/diagramevent/diagrameventaddimage.h
${QET_DIR}/sources/diagramevent/diagrameventaddshape.cpp
${QET_DIR}/sources/diagramevent/diagrameventaddshape.h
+ ${QET_DIR}/sources/diagramevent/diagrameventaddpath.cpp
+ ${QET_DIR}/sources/diagramevent/diagrameventaddpath.h
${QET_DIR}/sources/diagramevent/diagrameventaddtext.cpp
${QET_DIR}/sources/diagramevent/diagrameventaddtext.h
${QET_DIR}/sources/diagramevent/diagrameventinterface.cpp
@@ -518,6 +520,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/qetgraphicsitem/qetgraphicsitem.h
${QET_DIR}/sources/qetgraphicsitem/qetshapeitem.cpp
${QET_DIR}/sources/qetgraphicsitem/qetshapeitem.h
+ ${QET_DIR}/sources/qetgraphicsitem/shapetransform.cpp
+ ${QET_DIR}/sources/qetgraphicsitem/shapetransform.h
${QET_DIR}/sources/qetgraphicsitem/qgraphicsitemutility.cpp
${QET_DIR}/sources/qetgraphicsitem/qgraphicsitemutility.h
${QET_DIR}/sources/qetgraphicsitem/reportelement.cpp
@@ -718,8 +722,12 @@ set(QET_SRC_FILES
${QET_DIR}/sources/ui/elementpropertieswidget.h
${QET_DIR}/sources/ui/formulaassistantdialog.cpp
${QET_DIR}/sources/ui/formulaassistantdialog.h
+ ${QET_DIR}/sources/ui/imagecropdialog.cpp
+ ${QET_DIR}/sources/ui/imagecropdialog.h
${QET_DIR}/sources/ui/imagepropertieswidget.cpp
${QET_DIR}/sources/ui/imagepropertieswidget.h
+ ${QET_DIR}/sources/ui/imagetransparentcolordialog.cpp
+ ${QET_DIR}/sources/ui/imagetransparentcolordialog.h
${QET_DIR}/sources/ui/importelementdialog.cpp
${QET_DIR}/sources/ui/importelementdialog.h
${QET_DIR}/sources/ui/importelementtextpatterndialog.cpp
@@ -790,6 +798,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/undocommand/setautonumcontextcommand.h
${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp
${QET_DIR}/sources/undocommand/rotateselectioncommand.h
+ ${QET_DIR}/sources/undocommand/promoteshapecommand.cpp
+ ${QET_DIR}/sources/undocommand/promoteshapecommand.h
${QET_DIR}/sources/undocommand/rotatetextscommand.cpp
${QET_DIR}/sources/undocommand/rotatetextscommand.h
${QET_DIR}/sources/undocommand/movegraphicsitemcommand.cpp
diff --git a/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg b/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg
new file mode 100644
index 000000000..677ae6200
--- /dev/null
+++ b/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg
@@ -0,0 +1,12 @@
+
diff --git a/qelectrotech.qrc b/qelectrotech.qrc
index 531dde1f4..17fb20b03 100644
--- a/qelectrotech.qrc
+++ b/qelectrotech.qrc
@@ -548,6 +548,7 @@
ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-element.svgzico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-project.svgzico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-titleblock.svgz
+ ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svgico/16x16/object-group.pngico/mac_icon/elmt.icnsico/mac_icon/qelectrotech.icns
diff --git a/sources/diagramevent/diagrameventaddimage.cpp b/sources/diagramevent/diagrameventaddimage.cpp
index 8b091a75e..a8e145659 100644
--- a/sources/diagramevent/diagrameventaddimage.cpp
+++ b/sources/diagramevent/diagrameventaddimage.cpp
@@ -19,10 +19,14 @@
#include "diagrameventaddimage.h"
#include "../qetapp.h"
+#include "../qetdiagrameditor.h"
#include "../diagram.h"
#include "../undocommand/addgraphicsobjectcommand.h"
#include "../qetgraphicsitem/diagramimageitem.h"
+#include
+#include
+
/**
@brief DiagramEventAddImage::DiagramEventAddImage
Default constructor
@@ -34,6 +38,16 @@ DiagramEventAddImage::DiagramEventAddImage(Diagram *diagram) :
m_is_added (false)
{
openDialog();
+ if (m_running)
+ {
+ // Deferred for the same reason as the shape tools' own
+ // constructor-time hint: Diagram::setEventInterface() destroys
+ // whatever tool was previously active *after* this constructor
+ // returns, and that tool's own destructor clears the status bar
+ // -- an immediate show here would just get wiped out moments
+ // later by that cleanup.
+ QTimer::singleShot(0, this, [this]() { showHint(); });
+ }
}
/**
@@ -47,33 +61,61 @@ DiagramEventAddImage::~DiagramEventAddImage()
delete m_image;
}
+ if (!m_diagram->views().isEmpty())
+ {
+ if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()))
+ editor->statusBar()->clearMessage();
+ }
+
foreach (QGraphicsView *view, m_diagram->views())
view->setContextMenuPolicy((Qt::DefaultContextMenu));
}
+/**
+ @brief DiagramEventAddImage::showHint
+ Re-asserted on every move (see mouseMoveEvent), not just once at
+ activation: Qt's own built-in "show an action's statusTip on hover"
+ has its own internal "restore whatever was there before" logic for
+ when the hover ends. Since this message is first shown *during* that
+ same hover session (the user is still over the toolbar icon when the
+ deferred constructor-time call above fires), Qt's hover-tracking has
+ no idea this code changed the status bar in the meantime -- the
+ moment the mouse leaves the icon for the canvas, it silently
+ restores whatever it remembers being there before its own tip
+ started, overwriting this one. Re-showing it on every move within
+ the canvas simply outlasts that one-time restore -- the exact same
+ issue already found and fixed for the shape tools.
+*/
+void DiagramEventAddImage::showHint() const
+{
+ if (m_diagram->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()))
+ editor->statusBar()->showMessage(tr("Clic : positionner à la taille d'origine. "
+ "Cliquer-glisser : positionner et redimensionner. "
+ "Clic droit : pivoter de 90°. Ctrl+molette : ajuster la taille."));
+}
+
/**
@brief DiagramEventAddImage::mousePressEvent
- Action when mouse is pressed
+ Left button: starts a potential drag-to-resize, anchored here -- but
+ doesn't commit to anything yet. A quick click-release (see
+ mouseMoveEvent's threshold check) still places the image at its
+ original size, matching the previous behavior exactly; only an
+ actual drag switches to resizing. Right button still rotates in 90
+ degree steps, unchanged, and only while not already left-dragging.
@param event : event of mouse pressed
*/
void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
- if (m_image && event -> button() == Qt::LeftButton)
+ if (m_image && event->button() == Qt::LeftButton)
{
- QPointF pos = event->scenePos();
- pos.rx() -= m_image->boundingRect().width()/2;
- pos.ry() -= m_image->boundingRect().height()/2;
- m_diagram -> undoStack().push (new AddGraphicsObjectCommand(m_image, m_diagram, pos));
-
- for (QGraphicsView *view : m_diagram->views()) {
- view->setContextMenuPolicy((Qt::DefaultContextMenu));
- }
-
- m_running = false;
- emit finish();
+ m_pressed = true;
+ m_resize_engaged = false;
+ m_press_pos = event->scenePos();
event->setAccepted(true);
}
- else if (m_image && event -> button() == Qt::RightButton)
+ else if (m_image && !m_pressed && event->button() == Qt::RightButton)
{
m_image->setRotation(m_image->rotation() + 90);
event->setAccepted(true);
@@ -87,26 +129,95 @@ void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event)
*/
void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
- if (!m_image || event->buttons() != Qt::NoButton) {
+ if (!m_image) {
return;
}
-
+
+ showHint();
+
QPointF pos = event->scenePos();
-
+
if (!m_is_added)
{
for (QGraphicsView *view : m_diagram->views()) {
view->setContextMenuPolicy((Qt::NoContextMenu));
}
-
+
m_diagram->addItem(m_image);
m_is_added = true;
}
-
- m_image->setPos(pos - m_image->boundingRect().center());
+
+ if (m_pressed)
+ {
+ // Anchored on m_press_pos, not the item's own current position:
+ // dragging in any direction has to visibly grow the image from
+ // where the click started, not from wherever the "no button
+ // held" preview phase happened to leave it centered.
+ const QPointF delta = pos - m_press_pos;
+ if (!m_resize_engaged && QLineF(m_press_pos, pos).length() >= 4.0)
+ m_resize_engaged = true; // latched: crossing back within the threshold afterward must not un-engage it
+
+ if (!m_resize_engaged)
+ {
+ // Still just a (so far) plain click -- keep behaving like
+ // the pre-drag preview: original size, centered here, so
+ // releasing right now reproduces the old click-to-place
+ // behavior exactly.
+ m_image->setPos(m_press_pos - m_image->boundingRect().center());
+ }
+ else
+ {
+ const QSizeF naturalSize = m_image->boundingRect().size();
+ if (naturalSize.width() > 0 && naturalSize.height() > 0)
+ {
+ const qreal scaleX = qAbs(delta.x()) / naturalSize.width();
+ const qreal scaleY = qAbs(delta.y()) / naturalSize.height();
+ // The larger of the two, not a per-axis stretch: images
+ // only support a single uniform scale today (see
+ // boundingRect()/paint(), which never touch aspect
+ // ratio), so this is a diagonal-drag size, not a
+ // free-form one -- breaking aspect ratio on purpose is
+ // its own, separate, larger piece of work.
+ const qreal newScale = qBound(0.01, qMax(scaleX, scaleY), 50.0);
+ m_image->setScale(newScale);
+ }
+ m_image->setPos(qMin(m_press_pos.x(), pos.x()), qMin(m_press_pos.y(), pos.y()));
+ }
+ }
+ else
+ {
+ m_image->setPos(pos - m_image->boundingRect().center());
+ }
+
event->setAccepted(true);
}
+/**
+ @brief DiagramEventAddImage::mouseReleaseEvent
+ Left button release commits whatever mouseMoveEvent last set --
+ original size and centered if the press never turned into a real
+ drag, or the dragged-out size and position otherwise. Either way,
+ this is the only place placement is actually finalized now; a plain
+ click no longer finishes inside mousePressEvent itself, since it has
+ to wait and see whether a drag follows.
+ @param event : event of mouse release
+*/
+void DiagramEventAddImage::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (m_image && m_pressed && event->button() == Qt::LeftButton)
+ {
+ m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_image, m_diagram, m_image->pos()));
+
+ for (QGraphicsView *view : m_diagram->views()) {
+ view->setContextMenuPolicy((Qt::DefaultContextMenu));
+ }
+
+ m_running = false;
+ emit finish();
+ event->setAccepted(true);
+ }
+}
+
/**
@brief DiagramEventAddImage::mouseDoubleClickEvent
This method is used only to overwrite double click.
@@ -124,7 +235,14 @@ void DiagramEventAddImage::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event
*/
void DiagramEventAddImage::wheelEvent(QGraphicsSceneWheelEvent *event)
{
- if (!m_is_added || !m_image || event -> modifiers() != Qt::CTRL) {
+ // !m_pressed added alongside the modifier fix: without it, wheel
+ // scaling could fight with an active drag-resize, both trying to
+ // set scale() from different sources in the same gesture.
+ // event->modifiers() & Qt::ControlModifier, not != Qt::CTRL: the
+ // same exact-equality bug already found and fixed several times
+ // this session elsewhere -- Ctrl held together with any other
+ // modifier would silently fail to register as Ctrl at all.
+ if (!m_is_added || !m_image || m_pressed || !(event->modifiers() & Qt::ControlModifier)) {
return;
}
diff --git a/sources/diagramevent/diagrameventaddimage.h b/sources/diagramevent/diagrameventaddimage.h
index b81bf7aa9..48e45618d 100644
--- a/sources/diagramevent/diagrameventaddimage.h
+++ b/sources/diagramevent/diagrameventaddimage.h
@@ -20,6 +20,8 @@
#include "diagrameventinterface.h"
+#include
+
class Diagram;
class DiagramImageItem;
@@ -37,15 +39,20 @@ class DiagramEventAddImage : public DiagramEventInterface
void mousePressEvent (QGraphicsSceneMouseEvent *event) override;
void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override;
+ void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override;
void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override;
void wheelEvent (QGraphicsSceneWheelEvent *event) override;
bool isNull () const;
private:
void openDialog();
+ void showHint() const;
DiagramImageItem *m_image;
bool m_is_added;
+ bool m_pressed = false; // left button held: dragging out a size, not just positioning
+ bool m_resize_engaged = false; // latched once the drag threshold is crossed, matching the pen tool's own curve-drag threshold convention -- so dragging out and back near the start point doesn't "snap back" to original size before release
+ QPointF m_press_pos; // scene position of the left-button press, the resize anchor
};
#endif // DIAGRAMEVENTADDIMAGE_H
diff --git a/sources/diagramevent/diagrameventaddpath.cpp b/sources/diagramevent/diagrameventaddpath.cpp
new file mode 100644
index 000000000..f7e8b9c76
--- /dev/null
+++ b/sources/diagramevent/diagrameventaddpath.cpp
@@ -0,0 +1,427 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "diagrameventaddpath.h"
+
+#include "../diagram.h"
+#include "../lastusedstyle.h"
+#include "../qetapp.h"
+#include "../qetdiagrameditor.h"
+#include "../undocommand/addgraphicsobjectcommand.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+/**
+ @brief DiagramEventAddPath::DiagramEventAddPath
+ @param diagram : the diagram where this event must operate
+*/
+DiagramEventAddPath::DiagramEventAddPath(Diagram *diagram) :
+ DiagramEventInterface(diagram),
+ m_shape_item (nullptr),
+ m_help_horiz (nullptr),
+ m_help_verti (nullptr)
+{
+ m_running = true;
+ init();
+ // Deferred for the same reason as DiagramEventAddShape's own
+ // constructor-time hint: Diagram::setEventInterface() destroys
+ // whatever tool was previously active *after* this constructor
+ // returns, and that tool's own destructor clears the status bar --
+ // an immediate show here would just get wiped out moments later.
+ QTimer::singleShot(0, this, [this]() { showHint(); });
+}
+
+DiagramEventAddPath::~DiagramEventAddPath()
+{
+ if ((m_running || m_abort) && m_shape_item)
+ {
+ m_diagram->removeItem(m_shape_item);
+ delete m_shape_item;
+ }
+ delete m_help_horiz;
+ delete m_help_verti;
+
+ if (m_diagram && !m_diagram->views().isEmpty())
+ {
+ if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()))
+ editor->statusBar()->clearMessage();
+ }
+
+ foreach (QGraphicsView *v, m_diagram->views())
+ v->setContextMenuPolicy(Qt::DefaultContextMenu);
+}
+
+/**
+ @brief DiagramEventAddPath::showHint
+ Re-asserted on every move within the canvas (see mouseMoveEvent), not
+ just once at activation: Qt's own built-in "show an action's
+ statusTip on hover" has its own internal "restore whatever was there
+ before" logic for when the hover ends. Since this message is first
+ shown *during* that same hover session (the user is still over the
+ toolbar icon when the deferred constructor-time call above fires),
+ Qt's hover-tracking has no idea this code changed the status bar in
+ the meantime -- the moment the mouse leaves the icon for the canvas,
+ it silently restores whatever it remembers being there before its
+ own tip started, overwriting this one. Re-showing it on every move
+ within the canvas simply outlasts that one-time restore.
+*/
+void DiagramEventAddPath::showHint() const
+{
+ if (!m_diagram || m_diagram->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()))
+ editor->statusBar()->showMessage(tr("Clic: point anguleux. Cliquer-glisser: point courbe. "
+ "Clic sur le premier point: fermer. Échap/Entrée: terminer. "
+ "Clic droit: annuler le dernier point."));
+}
+
+void DiagramEventAddPath::init()
+{
+ foreach (QGraphicsView *v, m_diagram->views())
+ v->setContextMenuPolicy(Qt::NoContextMenu);
+}
+
+QPointF DiagramEventAddPath::snapped(const QPointF &scenePos, Qt::KeyboardModifiers mods) const
+{
+ return mods == Qt::ControlModifier ? scenePos : Diagram::snapToGrid(scenePos);
+}
+
+int DiagramEventAddPath::confirmedNodeCount() const
+{
+ // The trailing element is always the live preview while m_shape_item
+ // exists; with no shape yet there are no nodes of any kind.
+ return m_shape_item ? qMax(0, m_nodes.size() - 1) : 0;
+}
+
+/**
+ @brief DiagramEventAddPath::mousePressEvent
+ Left click: on the very first click, creates the shape with a real
+ node *and* an immediate preview node at the same spot, so a segment
+ exists (even if zero-length) from the start rather than requiring a
+ second click before anything is visible. On later clicks: either
+ confirms the live preview into a real point and appends a fresh one
+ for the next segment, or -- if close enough to the first node --
+ closes the path.
+*/
+void DiagramEventAddPath::mousePressEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (Q_UNLIKELY(m_diagram->isReadOnly()))
+ return;
+
+ if (event->button() != Qt::LeftButton)
+ {
+ // Accept every button while this tool is running, not just the
+ // one it actually acts on -- Diagram::mousePressEvent falls
+ // through to Qt's own default scene handling for anything left
+ // unaccepted, which is exactly the kind of competing control
+ // this tool can't afford while it's supposed to have exclusive
+ // ownership of input.
+ event->setAccepted(true);
+ return;
+ }
+
+ const QPointF pos = snapped(event->scenePos(), event->modifiers());
+
+ if (!m_shape_item)
+ {
+ m_shape_item = new QetShapeItem(pos, pos, QetShapeItem::Path);
+ if (LastUsedStyle::hasShapePen())
+ m_shape_item->setPen(LastUsedStyle::shapePen());
+ if (LastUsedStyle::hasShapeBrush())
+ m_shape_item->setBrush(LastUsedStyle::shapeBrush());
+ m_diagram->addItem(m_shape_item);
+ // Handles only ever get built for a selected item.
+ m_shape_item->setSelected(true);
+
+ QetShapeItem::PathNode node;
+ node.anchor = pos;
+ m_nodes << node;
+ m_nodes << node; // live preview, tracks the mouse from here on
+ m_shape_item->setPathNodes(m_nodes);
+ m_shape_item->enableNodeEditMode();
+
+ m_dragging_node = 0;
+ event->setAccepted(true);
+ return;
+ }
+
+ if (confirmedNodeCount() >= 2 && nearFirstNode(pos))
+ {
+ finishPath(true);
+ event->setAccepted(true);
+ return;
+ }
+
+ // Confirm the preview node as a real point, then append a fresh
+ // preview (a plain Corner, not a copy of the just-confirmed node's
+ // kind/handles) for the segment after it.
+ m_dragging_node = m_nodes.size() - 1;
+ m_nodes[m_dragging_node].anchor = pos;
+
+ QetShapeItem::PathNode preview;
+ preview.anchor = pos;
+ m_nodes << preview;
+
+ m_shape_item->setPathNodes(m_nodes);
+ m_shape_item->enableNodeEditMode();
+ event->setAccepted(true);
+}
+
+/**
+ @brief DiagramEventAddPath::mouseMoveEvent
+ Two mutually exclusive behaviours, matching whether a button is held:
+ with the left button down on a just-placed node, dragging shapes that
+ node's handles (same convention as editing an existing node -- see
+ QetShapeItem::dragPathControlHandle()). With no button held, the
+ trailing preview node instead tracks the mouse, giving the live
+ rubber-band segment.
+*/
+void DiagramEventAddPath::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
+{
+ updateHelpCross(event->scenePos());
+ showHint();
+
+ if (m_shape_item)
+ {
+ const QPointF pos = snapped(event->scenePos(), event->modifiers());
+
+ if (m_dragging_node >= 0 && (event->buttons() & Qt::LeftButton))
+ {
+ QetShapeItem::PathNode &node = m_nodes[m_dragging_node];
+ const QPointF delta = pos - node.anchor;
+
+ // A small threshold so an accidental few-pixel wobble on
+ // what was meant to be a plain click doesn't silently add
+ // curve handles the user never intended.
+ if (QLineF(QPointF(), delta).length() > 3.0)
+ {
+ node.kind = QetShapeItem::NodeKind::Smooth;
+ node.outHandle = delta;
+ node.inHandle = -delta;
+ }
+ else
+ {
+ node.kind = QetShapeItem::NodeKind::Corner;
+ node.outHandle.reset();
+ node.inHandle.reset();
+ }
+ m_shape_item->setPathNodes(m_nodes);
+ }
+ else if (!(event->buttons() & Qt::LeftButton) && !m_nodes.isEmpty())
+ {
+ m_nodes.last().anchor = pos;
+ m_shape_item->setPathNodes(m_nodes);
+ }
+ }
+
+ // Ours unconditionally while running: a stray, unaccepted move event
+ // falling through to Qt's default handling risks it dragging our
+ // selected, movable in-progress shape out from under the tool.
+ event->setAccepted(true);
+}
+
+/**
+ @brief DiagramEventAddPath::mouseReleaseEvent
+ Left release just ends the current node's drag phase (the trailing
+ preview resumes tracking the mouse on the next move). Right release
+ steps back one *confirmed* point (the preview is left alone), or
+ cancels outright once only one remains, or exits the tool entirely if
+ nothing is in progress at all.
+*/
+void DiagramEventAddPath::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (event->button() == Qt::LeftButton)
+ {
+ m_dragging_node = -1;
+ }
+ else if (event->button() == Qt::RightButton)
+ {
+ if (m_shape_item)
+ {
+ if (confirmedNodeCount() > 1)
+ {
+ m_nodes.remove(m_nodes.size() - 2); // the last *confirmed* node; keep the trailing preview
+ m_shape_item->setPathNodes(m_nodes);
+ }
+ else
+ {
+ cancelPath();
+ }
+ }
+ else
+ {
+ m_running = false;
+ emit finish();
+ }
+ }
+ event->setAccepted(true);
+}
+
+/**
+ @brief DiagramEventAddPath::mouseDoubleClickEvent
+ A double-click is a press, release, press, release, doubleclick
+ sequence -- the second press already confirmed the preview into a
+ duplicate point (mousePressEvent can't distinguish a double-click
+ from two single clicks in the same place) and appended a fresh
+ preview after it. Dropping the last node here removes that fresh
+ preview; finishPath()'s own trailing-preview removal then removes the
+ duplicate underneath it, leaving only the genuinely-placed points.
+*/
+void DiagramEventAddPath::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (m_shape_item && event->button() == Qt::LeftButton && !m_nodes.isEmpty())
+ {
+ m_nodes.removeLast();
+ finishPath(false);
+ }
+ event->setAccepted(true);
+}
+
+/**
+ @brief DiagramEventAddPath::keyPressEvent
+ Escape or Enter finish the path open once at least two real points
+ exist; Escape with fewer (or none placed at all) cancels/exits
+ instead, since there's nothing meaningful to keep.
+*/
+void DiagramEventAddPath::keyPressEvent(QKeyEvent *event)
+{
+ if (event->key() == Qt::Key_Escape)
+ {
+ if (m_shape_item && confirmedNodeCount() >= 2)
+ finishPath(false);
+ else if (m_shape_item)
+ cancelPath();
+ else
+ {
+ m_running = false;
+ emit finish();
+ }
+ event->accept();
+ }
+ else if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
+ && m_shape_item && confirmedNodeCount() >= 2)
+ {
+ finishPath(false);
+ event->accept();
+ }
+}
+
+/**
+ @brief DiagramEventAddPath::finishPath
+ Strips the trailing live-preview node, commits the in-progress path
+ onto the undo stack, and resets so the tool is ready to draw another
+ one immediately -- matching every other shape tool's own behaviour
+ after finishing a shape.
+*/
+void DiagramEventAddPath::finishPath(bool closed)
+{
+ if (!m_shape_item)
+ return;
+
+ if (!m_nodes.isEmpty())
+ m_nodes.removeLast();
+
+ if (m_nodes.size() < 2)
+ {
+ cancelPath();
+ return;
+ }
+
+ if (closed)
+ m_shape_item->setClosed(true);
+
+ m_shape_item->setPathNodes(m_nodes);
+ m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_shape_item, m_diagram));
+ m_shape_item = nullptr;
+ m_nodes.clear();
+ m_dragging_node = -1;
+}
+
+/**
+ @brief DiagramEventAddPath::cancelPath
+ Discards the in-progress path entirely -- nothing worth keeping (an
+ empty or single-point path isn't a usable shape).
+*/
+void DiagramEventAddPath::cancelPath()
+{
+ if (m_shape_item)
+ {
+ m_diagram->removeItem(m_shape_item);
+ delete m_shape_item;
+ m_shape_item = nullptr;
+ }
+ m_nodes.clear();
+ m_dragging_node = -1;
+}
+
+/**
+ @brief DiagramEventAddPath::nearFirstNode
+ m_shape_item's pos()/transform() stay at their identity defaults for
+ its entire construction here -- nothing during drawing ever touches
+ them -- so the first node's anchor, stored in local coordinates, is
+ directly comparable to a scene position without any mapping.
+*/
+bool DiagramEventAddPath::nearFirstNode(const QPointF &scenePos) const
+{
+ if (m_nodes.isEmpty())
+ return false;
+ return QLineF(m_nodes.first().anchor, scenePos).length() <= CLOSE_THRESHOLD;
+}
+
+/**
+ @brief DiagramEventAddPath::updateHelpCross
+ Same crosshair guide as every other shape tool (see
+ DiagramEventAddShape::updateHelpCross) -- duplicated rather than
+ shared, since the two classes don't otherwise share a common base
+ beyond DiagramEventInterface.
+*/
+void DiagramEventAddPath::updateHelpCross(const QPointF &p)
+{
+ if (!m_help_horiz || !m_help_verti)
+ {
+ QPen pen;
+ pen.setWidthF(0.4);
+ pen.setCosmetic(true);
+ pen.setColor(Diagram::background_color == Qt::darkGray ? Qt::lightGray : Qt::darkGray);
+
+ QRectF rect = m_diagram->border_and_titleblock.insideBorderRect();
+
+ if (!m_help_horiz)
+ {
+ m_help_horiz = new QGraphicsLineItem(rect.topLeft().x(), 0, rect.topRight().x(), 0);
+ m_help_horiz->setPen(pen);
+ m_diagram->addItem(m_help_horiz);
+ }
+
+ if (!m_help_verti)
+ {
+ m_help_verti = new QGraphicsLineItem(0, rect.topLeft().y(), 0, rect.bottomLeft().y());
+ m_help_verti->setPen(pen);
+ m_diagram->addItem(m_help_verti);
+ }
+ }
+
+ QPointF point = Diagram::snapToGrid(p);
+
+ m_help_horiz->setY(point.y());
+ m_help_verti->setX(point.x());
+}
diff --git a/sources/diagramevent/diagrameventaddpath.h b/sources/diagramevent/diagrameventaddpath.h
new file mode 100644
index 000000000..e93ac3ac9
--- /dev/null
+++ b/sources/diagramevent/diagrameventaddpath.h
@@ -0,0 +1,81 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef DIAGRAMEVENTADDPATH_H
+#define DIAGRAMEVENTADDPATH_H
+
+#include "../qetgraphicsitem/qetshapeitem.h"
+#include "diagrameventinterface.h"
+
+class QGraphicsLineItem;
+
+/**
+ @brief The DiagramEventAddPath class
+ Pen tool: interactively draw a new Path (Bezier) shape, following the
+ same vocabulary every vector editor's pen tool uses:
+ - click places a Corner node;
+ - press-drag-release places a Smooth node, with the drag defining a
+ pair of mirrored handles (same convention as
+ QetShapeItem::dragPathControlHandle's "Smooth" mirroring);
+ - clicking back on the first node closes the path;
+ - double-click, Enter, or Escape (once 2+ nodes exist) finishes it
+ open;
+ - right-click steps back one node;
+ - right-click or Escape with nothing placed yet cancels the tool.
+ While running, m_nodes always carries one extra "preview" node at the
+ end, tracking the mouse so a live rubber-band segment is always
+ visible -- confirmedNodeCount() excludes it; every public gesture
+ handler is responsible for stripping it before treating the list as
+ "the path so far" (see finishPath(), which does this once for every
+ finishing gesture).
+*/
+class DiagramEventAddPath : public DiagramEventInterface
+{
+ Q_OBJECT
+
+ public:
+ DiagramEventAddPath(Diagram *diagram);
+ ~DiagramEventAddPath() override;
+
+ void mousePressEvent (QGraphicsSceneMouseEvent *event) override;
+ void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override;
+ void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override;
+ void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override;
+ void keyPressEvent (QKeyEvent *event) override;
+ void init() override;
+
+ private:
+ void updateHelpCross (const QPointF &p);
+ void showHint () const;
+ void finishPath (bool closed);
+ void cancelPath ();
+ bool nearFirstNode (const QPointF &scenePos) const;
+ int confirmedNodeCount () const; // m_nodes always carries one trailing "preview" node while running; this excludes it
+ QPointF snapped (const QPointF &scenePos, Qt::KeyboardModifiers mods) const;
+
+ QetShapeItem *m_shape_item;
+ QVector m_nodes;
+ int m_dragging_node = -1;
+ QGraphicsLineItem *m_help_horiz, *m_help_verti;
+
+ // Scene units within which a click on an existing path is
+ // treated as "on the first node" and closes the shape, rather
+ // than adding yet another node right next to it.
+ static constexpr qreal CLOSE_THRESHOLD = 12.0;
+};
+
+#endif // DIAGRAMEVENTADDPATH_H
diff --git a/sources/diagramevent/diagrameventaddshape.cpp b/sources/diagramevent/diagrameventaddshape.cpp
index 0ab945266..d19669e76 100644
--- a/sources/diagramevent/diagrameventaddshape.cpp
+++ b/sources/diagramevent/diagrameventaddshape.cpp
@@ -19,8 +19,16 @@
#include "../diagram.h"
#include "../lastusedstyle.h"
+#include "../qetapp.h"
+#include "../qetdiagrameditor.h"
#include "../undocommand/addgraphicsobjectcommand.h"
+#include
+#include
+#include
+#include
+#include
+
/**
@brief DiagramEventAddShape::DiagramEventAddShape
Default constructor
@@ -36,6 +44,18 @@ DiagramEventAddShape::DiagramEventAddShape(Diagram *diagram, QetShapeItem::Shape
{
m_running = true;
init();
+ // Deferred to the next event-loop iteration, not shown immediately:
+ // Diagram::setEventInterface() destroys whatever tool was
+ // previously active *after* this constructor returns, and that
+ // previous tool's own destructor clears the status bar (see
+ // ~DiagramEventAddShape() below) -- an immediate show here would
+ // just get wiped out moments later by that cleanup. Letting the old
+ // tool's teardown finish first, then showing this one's message, is
+ // the same fix already used for the tooltip-flicker issue in
+ // QetShapeItem::refreshInteractionHints(), applied to the same
+ // class of "something later in the same call chain undoes what I
+ // just did" ordering problem.
+ QTimer::singleShot(0, this, [this]() { updateCreationHint(); });
}
/**
@@ -50,11 +70,123 @@ DiagramEventAddShape::~DiagramEventAddShape()
}
delete m_help_horiz;
delete m_help_verti;
+ delete m_center_marker;
+
+ if (m_diagram && !m_diagram->views().isEmpty())
+ {
+ if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()))
+ editor->statusBar()->clearMessage();
+ }
foreach (QGraphicsView *v, m_diagram->views())
v->setContextMenuPolicy(Qt::DefaultContextMenu);
}
+/**
+ @brief DiagramEventAddShape::applyPosition
+ Applies a drag/click position to the in-progress shape, honouring two
+ modifiers that mirror how the very same shape can already be edited
+ afterward, once placed:
+ - Ctrl, for Rectangle/Ellipse only: the first click becomes the
+ shape's *center* rather than a corner, growing symmetrically as
+ the cursor moves away from it -- the same meaning Ctrl already
+ has on a Resize handle (anchor at center). Deliberately not
+ offered for Line: unlike the Rectangle/Ellipse case, there's no
+ established convention for "a line grows symmetrically from its
+ middle" to justify it by, so Ctrl for Line means only free
+ positioning (see the plain grid-snap check above), nothing more.
+ - Shift, for Rectangle/Ellipse only: forces the bounding box square
+ (so Ellipse becomes a true circle), using whichever of the two
+ dragged dimensions is currently larger and mirroring that onto
+ the other, preserving the direction the user is actually
+ dragging in.
+ Both can combine (Ctrl+Shift: a centered square/circle). Whether or
+ not Ctrl is currently held, the non-anchored branch always rebuilds
+ from m_anchor_point rather than nudging the existing rect/line --
+ otherwise, if Ctrl had been held earlier in the same drag (moving the
+ shape's own first point to a mirrored position), releasing it would
+ leave that point stuck there instead of actually restoring it.
+*/
+void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifiers mods)
+{
+ if (!m_shape_item)
+ return;
+
+ if (m_shape_type == QetShapeItem::Polygon)
+ {
+ // setP2() has its own dedicated Polygon handling: it moves the
+ // *last* vertex in place rather than setting a second point,
+ // which is exactly the live "next segment follows the mouse"
+ // preview -- the same idea DiagramEventAddPath's trailing
+ // preview node gives the pen tool. No Ctrl/Shift modifiers apply
+ // here (those are Rectangle/Ellipse/Line-specific below), so
+ // this is a direct, unconditional call.
+ m_shape_item->setP2(pos);
+ return;
+ }
+
+ // m_center_anchored is decided once, in mousePressEvent, not
+ // re-checked here on every call -- re-checking it live meant
+ // releasing Ctrl mid-drag (something you'd naturally do the moment
+ // your hand gets tired holding it, long before you're done resizing)
+ // silently snapped the shape back to corner-anchored, discarding
+ // what felt like an already-made decision. Deciding it once at the
+ // first click matches "I held Ctrl when I clicked, so this shape is
+ // centered" -- a single, predictable rule instead of a live toggle.
+ QPointF target = pos;
+
+ if ((mods & Qt::ShiftModifier)
+ && (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse))
+ {
+ const QPointF ref = m_anchor_point;
+ const qreal dx = target.x() - ref.x();
+ const qreal dy = target.y() - ref.y();
+ const qreal size = qMax(qAbs(dx), qAbs(dy));
+ target.setX(ref.x() + (dx < 0 ? -size : size));
+ target.setY(ref.y() + (dy < 0 ? -size : size));
+ }
+
+ if (m_center_anchored)
+ {
+ const QPointF mirrored = 2 * m_anchor_point - target;
+ m_shape_item->setRect(QRectF(mirrored, target));
+ }
+ else
+ {
+ if (m_shape_type == QetShapeItem::Line)
+ m_shape_item->setLine(QLineF(m_anchor_point, target));
+ else
+ m_shape_item->setRect(QRectF(m_anchor_point, target));
+ }
+}
+
+/**
+ @brief DiagramEventAddShape::showCenterMarker
+ Small, filled marker at the anchor point, shown only while Ctrl-
+ anchoring is actually in effect right now (see applyPosition()) --
+ doubling as live confirmation that it is, rather than leaving the
+ user to infer it purely from how the shape happens to be growing.
+*/
+void DiagramEventAddShape::showCenterMarker(const QPointF &scenePos)
+{
+ if (!m_center_marker)
+ {
+ m_center_marker = new QGraphicsEllipseItem(-4, -4, 8, 8);
+ QPen pen(Qt::red);
+ pen.setCosmetic(true);
+ m_center_marker->setPen(pen);
+ m_center_marker->setBrush(Qt::red);
+ m_diagram->addItem(m_center_marker);
+ }
+ m_center_marker->setPos(scenePos);
+}
+
+void DiagramEventAddShape::hideCenterMarker()
+{
+ delete m_center_marker;
+ m_center_marker = nullptr;
+}
+
/**
@brief DiagramEventAddShape::mousePressEvent
Action when mouse is pressed
@@ -67,7 +199,14 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
}
QPointF pos = event->scenePos();
- if (event->modifiers() != Qt::ControlModifier) {
+ // A bitwise flag check, not exact equality: modifiers() == Ctrl
+ // alone fails the moment any other key (Shift for the square/circle
+ // lock, or an incidental platform flag) is also held, silently
+ // falling through to snapToGrid() even though Ctrl is held --
+ // exactly what made Ctrl+Shift together feel "frozen" (both grid-
+ // snapped *and* square-locked, quantizing to whichever is coarser)
+ // and made "Ctrl = free positioning" not actually hold up.
+ if (!(event->modifiers() & Qt::ControlModifier)) {
pos = Diagram::snapToGrid(pos);
}
@@ -78,6 +217,15 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
if (!m_shape_item)
{
m_shape_item = new QetShapeItem(pos, pos, m_shape_type);
+ m_anchor_point = pos;
+ // Decided once, here, rather than re-checked on every mouse
+ // move for the rest of the drag -- see applyPosition()'s doc
+ // comment for why continuous re-checking made releasing Ctrl
+ // mid-drag feel like a bug rather than a deliberate choice.
+ m_center_anchored = (event->modifiers() & Qt::ControlModifier)
+ && (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse);
+ if (m_center_anchored)
+ showCenterMarker(m_anchor_point);
//Start from whatever pen/brush was last applied this
//session, rather than always the hardcoded default.
if (LastUsedStyle::hasShapePen()) {
@@ -87,6 +235,7 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
m_shape_item->setBrush(LastUsedStyle::shapeBrush());
}
m_diagram->addItem (m_shape_item);
+ updateCreationHint();
event->setAccepted(true);
return;
}
@@ -94,12 +243,14 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event)
//If current item isn't a polyline, add it with an undo command
if (m_shape_type != QetShapeItem::Polygon)
{
- m_shape_item->setP2 (pos);
+ applyPosition(pos, event->modifiers());
if (m_shape_item->shapeType() == QetShapeItem::Rectangle || m_shape_item->shapeType() == QetShapeItem::Ellipse) {
m_shape_item->setRect(m_shape_item->rect().normalized());
}
m_diagram->undoStack().push (new AddGraphicsObjectCommand(m_shape_item, m_diagram));
m_shape_item = nullptr; //< set to nullptr for create new shape at next left clic
+ hideCenterMarker();
+ updateCreationHint();
}
//Else add a new point to polyline
else
@@ -125,18 +276,76 @@ void DiagramEventAddShape::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
updateHelpCross(event->scenePos());
+ // Re-asserted on every move, not just once at activation: Qt's own
+ // built-in "show an action's statusTip on hover" has its own
+ // internal "restore whatever was there before" logic for when the
+ // hover ends. Since our message is shown *during* that same hover
+ // session (the user is still over the toolbar icon when the
+ // deferred constructor-time call fires), Qt's hover-tracking has no
+ // idea we changed the status bar in the meantime -- the moment the
+ // mouse leaves the icon for the canvas, it "restores" to whatever it
+ // remembers being there before its own tip started, which is stale
+ // and empty, silently overwriting ours. Re-showing it here, on every
+ // move within the canvas, simply outlasts that one-time restore.
+ updateCreationHint();
+
if (m_shape_item && event->buttons() == Qt::NoButton)
{
+ m_last_mouse_scene_pos = event->scenePos(); // raw, before snapping -- see reapplyLastPosition()
+
QPointF pos = event->scenePos();
- if (event->modifiers() != Qt::ControlModifier) {
+ if (!(event->modifiers() & Qt::ControlModifier)) {
pos = Diagram::snapToGrid(pos);
}
- m_shape_item->setP2 (pos);
+ applyPosition(pos, event->modifiers());
event->setAccepted(true);
}
}
+/**
+ @brief DiagramEventAddShape::keyPressEvent / keyReleaseEvent
+ Pressing or releasing Shift (the square/circle lock) does nothing
+ visible on its own -- applyPosition() only ever runs from
+ mouseMoveEvent, so without this, a keyboard-only change just sits
+ there until the next, often incidental, pixel of mouse movement
+ brings the shape in line with it. That's exactly what looked like a
+ freeze: holding Shift while the mouse is genuinely still produces no
+ visible change (correctly -- nothing has moved), and it only
+ "unsticks" once the mouse moves again, which released keys tend to
+ coincide with purely by hand tremor, not because releasing itself
+ did anything. Re-running the last known mouse position through
+ applyPosition() here makes the key press or release itself the
+ trigger, giving immediate feedback instead of waiting on chance.
+*/
+void DiagramEventAddShape::keyPressEvent(QKeyEvent *event)
+{
+ reapplyLastPosition(event);
+}
+
+void DiagramEventAddShape::keyReleaseEvent(QKeyEvent *event)
+{
+ reapplyLastPosition(event);
+}
+
+void DiagramEventAddShape::reapplyLastPosition(QKeyEvent *event)
+{
+ if (!m_shape_item || (event->key() != Qt::Key_Shift && event->key() != Qt::Key_Control))
+ return;
+
+ // A fresh, global query, not event->modifiers(): for a press/release
+ // of a modifier key itself, whether that key is already reflected in
+ // the key event's own modifiers() is ambiguous and platform-
+ // dependent -- the same reason Diagram::snapToGrid() queries this
+ // directly rather than trusting a passed-in modifiers() value.
+ const Qt::KeyboardModifiers mods = QGuiApplication::keyboardModifiers();
+ QPointF pos = m_last_mouse_scene_pos;
+ if (!(mods & Qt::ControlModifier))
+ pos = Diagram::snapToGrid(pos);
+
+ applyPosition(pos, mods);
+}
+
/**
@brief DiagramEventAddShape::mouseReleaseEvent
Action when mouse button is released
@@ -155,7 +364,7 @@ void DiagramEventAddShape::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
m_shape_item->removePoints();
QPointF pos = event->scenePos();
- if (event->modifiers() != Qt::ControlModifier)
+ if (!(event->modifiers() & Qt::ControlModifier))
pos = Diagram::snapToGrid(pos);
m_shape_item->setP2(pos); //Set the new last point under the cursor
@@ -167,6 +376,8 @@ void DiagramEventAddShape::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
m_diagram->removeItem(m_shape_item);
delete m_shape_item;
m_shape_item = nullptr;
+ hideCenterMarker();
+ updateCreationHint();
event->setAccepted(true);
return;
}
@@ -203,6 +414,8 @@ void DiagramEventAddShape::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event
}
m_diagram->undoStack().push (new AddGraphicsObjectCommand(m_shape_item, m_diagram));
m_shape_item = nullptr; //< set to nullptr for create new shape at next left clic
+ hideCenterMarker();
+ updateCreationHint();
event->setAccepted(true);
}
}
@@ -213,6 +426,59 @@ void DiagramEventAddShape::init()
v->setContextMenuPolicy(Qt::NoContextMenu);
}
+/**
+ @brief DiagramEventAddShape::updateCreationHint
+ Shows whichever of beforeClickHint()/afterClickHint() matches the
+ current phase -- there was previously either no message at all
+ (Line/Rectangle/Ellipse) or a single static one that never changed
+ regardless of progress (Polygon, set externally in
+ QETDiagramEditor::addItemGroupTriggered()); this replaces both with
+ one phase-aware message per shape type, managed by the tool itself.
+*/
+void DiagramEventAddShape::updateCreationHint() const
+{
+ if (!m_diagram || m_diagram->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst()))
+ editor->statusBar()->showMessage(m_shape_item ? afterClickHint() : beforeClickHint());
+}
+
+QString DiagramEventAddShape::beforeClickHint() const
+{
+ switch (m_shape_type)
+ {
+ case QetShapeItem::Line:
+ return tr("Clic gauche : positionner le point de départ (Ctrl = position libre)");
+ case QetShapeItem::Rectangle:
+ case QetShapeItem::Ellipse:
+ return tr("Clic gauche : positionner le premier coin (Ctrl = point central, position libre)");
+ case QetShapeItem::Polygon:
+ return tr("Clic gauche : positionner le premier point (Ctrl = position libre)");
+ default:
+ return QString();
+ }
+}
+
+QString DiagramEventAddShape::afterClickHint() const
+{
+ switch (m_shape_type)
+ {
+ case QetShapeItem::Line:
+ return tr("Clic gauche : positionner le point final (Ctrl = position libre) ; clic droit : annuler");
+ case QetShapeItem::Rectangle:
+ return tr("Clic gauche : positionner le coin opposé (Maj = carré, "
+ "Ctrl = depuis le centre + position libre, Ctrl+Maj = carré centré) ; clic droit : annuler");
+ case QetShapeItem::Ellipse:
+ return tr("Clic gauche : positionner le coin opposé (Maj = cercle, "
+ "Ctrl = depuis le centre + position libre, Ctrl+Maj = cercle centré) ; clic droit : annuler");
+ case QetShapeItem::Polygon:
+ return tr("Clic gauche : point suivant ; double-clic ou Entrée : terminer ; "
+ "clic droit : annuler le dernier point");
+ default:
+ return QString();
+ }
+}
+
/**
@brief DiagramEventAddShape::updateHelpCross
Create and update the position of the cross to help user for draw new shape
diff --git a/sources/diagramevent/diagrameventaddshape.h b/sources/diagramevent/diagrameventaddshape.h
index 60a66615a..7999897f6 100644
--- a/sources/diagramevent/diagrameventaddshape.h
+++ b/sources/diagramevent/diagrameventaddshape.h
@@ -21,6 +21,8 @@
#include "../qetgraphicsitem/qetshapeitem.h"
#include "diagrameventinterface.h"
+class QGraphicsEllipseItem;
+
/**
@brief The DiagramEventAddShape class
This event manage the creation of a shape.
@@ -37,15 +39,28 @@ class DiagramEventAddShape : public DiagramEventInterface
void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override;
void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override;
void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override;
+ void keyPressEvent (QKeyEvent *event) override;
+ void keyReleaseEvent (QKeyEvent *event) override;
void init() override;
private:
void updateHelpCross (const QPointF &p);
+ void applyPosition (const QPointF &pos, Qt::KeyboardModifiers mods);
+ void updateCreationHint () const;
+ QString beforeClickHint () const;
+ QString afterClickHint () const;
+ void showCenterMarker (const QPointF &scenePos);
+ void hideCenterMarker ();
+ void reapplyLastPosition (QKeyEvent *event);
protected:
QetShapeItem::ShapeType m_shape_type;
QetShapeItem *m_shape_item;
QGraphicsLineItem *m_help_horiz, *m_help_verti;
+ QPointF m_anchor_point; // the shape's first-click point -- meaningful once m_shape_item exists
+ QGraphicsEllipseItem *m_center_marker = nullptr; // shown only while Ctrl-anchoring is actually in effect, so it doubles as confirmation that it is
+ bool m_center_anchored = false; // decided once, at the first click -- see applyPosition()'s doc comment for why
+ QPointF m_last_mouse_scene_pos; // raw, unsnapped -- lets a modifier-only change re-snap correctly when reapplied
};
#endif // DIAGRAMEVENTADDSHAPE_H
diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp
index a17412a72..566b6f5c6 100644
--- a/sources/qetdiagrameditor.cpp
+++ b/sources/qetdiagrameditor.cpp
@@ -27,6 +27,7 @@
#include "diagramevent/diagrameventaddpdf.h"
#endif
#include "diagramevent/diagrameventaddshape.h"
+#include "diagramevent/diagrameventaddpath.h"
#include "diagramevent/diagrameventaddtext.h"
#include "diagramview.h"
#include "elementspanelwidget.h"
@@ -60,6 +61,7 @@
#include
#include
#include
+#include
#ifdef BUILD_WITHOUT_KF
# include "ui/nokde/kautosavefile.h"
#else
@@ -718,6 +720,19 @@ void QETDiagramEditor::setUpActions()
connect(&m_zoom_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::zoomGroupTriggered);
//Adding action (add text, image, shape...)
+ // Exclusive (the default) prevents the active action from ever being
+ // unchecked by clicking it again -- Qt only lets you switch to a
+ // different one. That's exactly why clicking an already-active
+ // tool's own icon never actually deactivated it: confirmed by
+ // logging every addItemGroupTriggered() call and finding
+ // isChecked()==true on *every* click, including the one meant to
+ // turn the tool off -- the click-handling code's own "was this an
+ // uncheck?" check was correct, its precondition just could never
+ // occur. ExclusiveOptional allows exactly one more transition:
+ // clicking the currently-checked action unchecks it, leaving none
+ // checked, which is required for "click the active tool to turn it
+ // off" to mean anything at the QAction level at all.
+ m_add_item_actions_group.setExclusionPolicy(QActionGroup::ExclusionPolicy::ExclusiveOptional);
QAction *add_text = m_add_item_actions_group.addAction(QET::Icons::PartTextField, tr("Ajouter un champ de texte"));
QAction *add_image = m_add_item_actions_group.addAction(QET::Icons::adding_image, tr("Ajouter une image"));
#ifdef QET_HAS_QTPDF
@@ -727,6 +742,7 @@ void QETDiagramEditor::setUpActions()
QAction *add_rectangle = m_add_item_actions_group.addAction(QET::Icons::PartRectangle, tr("Ajouter un rectangle"));
QAction *add_ellipse = m_add_item_actions_group.addAction(QET::Icons::PartEllipse, tr("Ajouter une ellipse"));
QAction *add_polyline = m_add_item_actions_group.addAction(QET::Icons::PartPolygon, tr("Ajouter une polyligne"));
+ QAction *add_path = m_add_item_actions_group.addAction(QET::Icons::PartBezier, tr("Ajouter une courbe"));
QAction *add_terminal_strip = m_add_item_actions_group.addAction(QET::Icons::TerminalStrip, tr("Ajouter un plan de bornes"));
add_text ->setStatusTip(tr("Ajoute un champ de texte sur le folio actuel"));
@@ -738,6 +754,7 @@ void QETDiagramEditor::setUpActions()
add_rectangle->setStatusTip(tr("Ajoute un rectangle sur le folio actuel"));
add_ellipse ->setStatusTip(tr("Ajoute une ellipse sur le folio actuel"));
add_polyline ->setStatusTip(tr("Ajoute une polyligne sur le folio actuel"));
+ add_path ->setStatusTip(tr("Ajoute une courbe de Bézier sur le folio actuel"));
add_terminal_strip->setStatusTip(tr("Ajoute un plan de bornier sur le folio actuel"));
add_text ->setData(QStringLiteral("text"));
@@ -749,6 +766,7 @@ void QETDiagramEditor::setUpActions()
add_rectangle->setData(QStringLiteral("rectangle"));
add_ellipse ->setData(QStringLiteral("ellipse"));
add_polyline ->setData(QStringLiteral("polyline"));
+ add_path ->setData(QStringLiteral("path"));
add_terminal_strip->setData(QStringLiteral("terminal_strip"));
add_text->setCheckable(true);
@@ -756,6 +774,7 @@ void QETDiagramEditor::setUpActions()
add_rectangle->setCheckable(true);
add_ellipse->setCheckable(true);
add_polyline->setCheckable(true);
+ add_path->setCheckable(true);
connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered);
@@ -1555,6 +1574,27 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action)
if (Q_UNLIKELY (!currentDiagramView() || !currentDiagramView()->diagram() || value.isEmpty())) return;
Diagram *d = currentDiagramView()->diagram();
+
+ // This action group allows deselecting the currently-active tool by
+ // clicking its own icon again, not just switching between tools --
+ // that click still fires this slot (QActionGroup::triggered fires on
+ // every click in the group, checked-state-changing or not), so
+ // without this check it would unconditionally construct *another*
+ // instance of the same tool and reactivate it, leaving the tool
+ // fully active in the canvas while its own toolbar button shows
+ // unchecked -- exactly backwards from what the click was for.
+ // Scoped to checkable actions specifically: image/pdf/terminal_strip
+ // are one-shot actions (pick a file, open a dialog) with no
+ // persistent "active tool" state at all, and aren't even checkable
+ // -- isChecked() on them is always false, so without this guard
+ // they'd hit the branch above on every single click and never
+ // reach their own handling below.
+ if (action->isCheckable() && !action->isChecked())
+ {
+ d->clearEventInterface();
+ return;
+ }
+
DiagramEventInterface *diagram_event = nullptr;
if (value == "line")
@@ -1564,12 +1604,10 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action)
else if (value == "ellipse")
diagram_event = new DiagramEventAddShape (d, QetShapeItem::Ellipse);
else if (value == "polyline")
- {
diagram_event = new DiagramEventAddShape (d, QetShapeItem::Polygon);
- statusBar()-> showMessage(tr("Double-click pour terminer la forme, Click droit pour annuler le dernier point"));
- connect(diagram_event, &DiagramEventInterface::destroyed, [this]() {
- statusBar()->clearMessage();
- });
+ else if (value == "path")
+ {
+ diagram_event = new DiagramEventAddPath (d);
}
else if (value == "image")
{
@@ -1612,6 +1650,22 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action)
{
d->setEventInterface(diagram_event);
connect(diagram_event, &DiagramEventInterface::destroyed, [action]() {action->setChecked(false);});
+ // Defensive: on this style/theme, the toolbar button bound to an
+ // exclusive-group action doesn't reliably repaint its checked
+ // appearance after the group briefly had *no* action checked at
+ // all (the gap between unchecking one tool and checking one
+ // again, even the same one) -- confirmed by tracing a real
+ // recording frame by frame: the status bar correctly showed this
+ // tool's "before first click" hint (which can only appear from
+ // inside a freshly-constructed tool's own constructor, so the
+ // action's checked state and the tool's activation were both
+ // genuinely correct) while the button itself stayed visually
+ // unchecked for a sustained period. Forcing an explicit repaint
+ // here makes the button's appearance match its actual state
+ // regardless of whether Qt's own change notification fired
+ // correctly.
+ if (QWidget *button = m_add_item_tool_bar->widgetForAction(action))
+ button->update();
}
}
diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp
index 8a7649399..f6fe4bf72 100644
--- a/sources/qetgraphicsitem/diagramimageitem.cpp
+++ b/sources/qetgraphicsitem/diagramimageitem.cpp
@@ -18,8 +18,23 @@
#include "diagramimageitem.h"
#include "../PropertiesEditor/propertieseditordialog.h"
+#include "../QPropertyUndoCommand/qpropertyundocommand.h"
#include "../diagram.h"
+#include "../diagramview.h"
+#include "../qet.h"
+#include "../qetapp.h"
+#include "../qetdiagrameditor.h"
#include "../ui/imagepropertieswidget.h"
+#include "../ui/imagecropdialog.h"
+#include "../ui/imagetransparentcolordialog.h"
+#include "../utils/qetutils.h"
+#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
+
+#include
+#include
+#include
+#include
+#include
/**
@brief DiagramImageItem::DiagramImageItem
@@ -30,6 +45,7 @@ DiagramImageItem::DiagramImageItem(QetGraphicsItem *parent_item):
QetGraphicsItem(parent_item)
{
setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges);
+ setAcceptHoverEvents(true);
}
/**
@@ -40,10 +56,18 @@ DiagramImageItem::DiagramImageItem(QetGraphicsItem *parent_item):
*/
DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *parent_item):
QetGraphicsItem(parent_item),
- pixmap_(pixmap)
+ pixmap_(pixmap),
+ m_base_pixmap(pixmap),
+ m_crop_rect(pixmap.rect())
{
- setTransformOriginPoint(boundingRect().center());
+ // m_transform.toMatrix(), not QGraphicsItem::setRotation()/setScale():
+ // those are a single uniform scale() float, which is exactly why an
+ // image could never break its own aspect ratio before this class
+ // gained proper independent scaleX/scaleY.
+ m_transform.pivot = boundingRect().center();
+ setTransform(m_transform.toMatrix());
setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges);
+ setAcceptHoverEvents(true);
}
/**
@@ -52,6 +76,8 @@ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *paren
*/
DiagramImageItem::~DiagramImageItem()
{
+ if (!m_handler_vector.isEmpty())
+ qDeleteAll(m_handler_vector);
}
/**
@@ -98,8 +124,963 @@ void DiagramImageItem::editProperty()
@param pixmap the new pixmap
*/
void DiagramImageItem::setPixmap(const QPixmap &pixmap) {
+ // Only ever mattered once setPixmap() could be called on an
+ // already-placed item with a differently-sized replacement (see
+ // replaceImage()) -- previously this only ran from the constructor,
+ // before the item existed in any scene, where there was nothing yet
+ // to invalidate. Without this, a same-session replace to a
+ // different-sized image would leave the scene's bounding-rect cache
+ // stale, the exact class of bug already fixed multiple times this
+ // session for shapes.
+ //
+ // Deliberately does NOT touch m_transform.pivot/pos() here, even
+ // though a differently-sized pixmap generally means the old pivot
+ // (a bounding-rect-relative point) no longer means the same thing:
+ // crop() and replaceImage(), the only two callers that ever change
+ // the pixmap's size, each have their own specific idea of what
+ // should happen to position and pivot when they do (see their own
+ // comments) -- a single generic rule here would fight with
+ // whichever of them actually needs something more specific.
+ prepareGeometryChange();
pixmap_ = pixmap;
- setTransformOriginPoint(boundingRect().center());
+ emit pixmapChanged();
+}
+
+/**
+ @brief DiagramImageItem::setScaleFactorX / setScaleFactorY / setRotationAngle
+ Matching QetShapeItem's own setters for the identical fields --
+ same pattern, same reasoning: change the one field, rebuild the
+ matrix, notify. No pivot or position compensation needed here,
+ unlike setPivot() below, since neither scale nor rotation moves the
+ pivot itself.
+*/
+void DiagramImageItem::setScaleFactorX(qreal factor)
+{
+ if (qFuzzyCompare(m_transform.scaleX, factor)) return;
+ prepareGeometryChange();
+ m_transform.scaleX = factor;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void DiagramImageItem::setScaleFactorY(qreal factor)
+{
+ if (qFuzzyCompare(m_transform.scaleY, factor)) return;
+ prepareGeometryChange();
+ m_transform.scaleY = factor;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void DiagramImageItem::setRotationAngle(qreal angle)
+{
+ if (qFuzzyCompare(m_transform.rotation, angle)) return;
+ prepareGeometryChange();
+ m_transform.rotation = angle;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void DiagramImageItem::setSkewX(qreal skew)
+{
+ if (qFuzzyCompare(m_transform.skewX, skew)) return;
+ prepareGeometryChange();
+ m_transform.skewX = skew;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void DiagramImageItem::setSkewY(qreal skew)
+{
+ if (qFuzzyCompare(m_transform.skewY, skew)) return;
+ prepareGeometryChange();
+ m_transform.skewY = skew;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+/**
+ @brief DiagramImageItem::setPivot
+ Unlike scale/rotation, moving the pivot alone WOULD visibly shift
+ the image on screen, since the pivot is baked directly into the
+ transform matrix -- compensatedPositionForNewPivot() (already
+ proven correct for QetShapeItem's identical need) solves for
+ whatever pos() keeps the image exactly where it already was, so
+ only the *handle* moves, not the picture underneath it.
+*/
+void DiagramImageItem::setPivot(const QPointF &newPivot)
+{
+ if (m_transform.pivot == newPivot) return;
+ const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, newPivot, m_transform.linearPart());
+ prepareGeometryChange();
+ m_transform.pivot = newPivot;
+ // Verified numerically that the underlying math keeps the image
+ // exactly in place when the pivot moves -- this guard is about a
+ // DIFFERENT problem: setPos() and setTransform() are two separate
+ // QGraphicsItem operations, each independently triggering
+ // itemChange(), so without this, repositionHandles() (and,
+ // visibly, a render) could happen using the new pos() but the
+ // still-old transform (or vice versa) in the moment between the
+ // two calls -- a real, if brief, wobble, not a calculation error.
+ // QetShapeItem's own setPivot() already needed the identical fix
+ // for the identical reason.
+ m_deferHandleReposition = true;
+ // QGraphicsObject::setPos(), not the plain setPos() this class
+ // would otherwise inherit: QetGraphicsItem overrides setPos() to
+ // snap to the diagram's grid, which is exactly right for a user
+ // dragging the whole image around, but silently corrupts this
+ // specific, exactly-computed compensation -- rounding it to the
+ // nearest grid point defeats the entire point of computing an
+ // exact value in the first place, and was the actual cause of the
+ // "picture drifts slightly" symptom (invisible at scale 1 with no
+ // rotation, since the compensation is a no-op there regardless).
+ QGraphicsObject::setPos(newPos);
+ setTransform(m_transform.toMatrix());
+ m_deferHandleReposition = false;
+ repositionHandles();
+ emit transformChanged();
+}
+
+/**
+ @brief DiagramImageItem::setPivotRaw
+ Sets m_transform.pivot directly, with NO position compensation at
+ all -- deliberately, unlike setPivot() above. Exists solely for
+ crop()'s own undo chain (see the Q_PROPERTY declaration's comment
+ for why): crop() already computes the fully correct pos() itself,
+ accounting for the crop, and pushes that as its own independent
+ undo step, so compensating pos() *again* here would silently
+ corrupt it back to a wrong value the moment this step replays.
+ Never call this expecting the image to stay visually in place on
+ its own -- it won't; the caller is responsible for that.
+*/
+void DiagramImageItem::setPivotRaw(const QPointF &newPivot)
+{
+ if (m_transform.pivot == newPivot) return;
+ prepareGeometryChange();
+ m_transform.pivot = newPivot;
+ setTransform(m_transform.toMatrix());
+ repositionHandles();
+ emit transformChanged();
+}
+
+/**
+ @brief DiagramImageItem::resetPivotToBoundingRectCenter
+ Used after a resize drag ends (see handlerMouseReleaseEvent()) --
+ NOT after crop() or replaceImage(), which each compute their own,
+ more specific position handling instead (see setPixmap()'s comment
+ for why a single generic rule doesn't fit both of those).
+*/
+void DiagramImageItem::resetPivotToBoundingRectCenter()
+{
+ m_pivotIsCustom = false;
+ setPivot(boundingRect().center());
+}
+
+namespace {
+ // Local-space (natural, unscaled pixmap size) position of each of
+ // the 8 resize handles. Indices: 0=TL 1=TM 2=TR 3=ML 4=MR 5=BL
+ // 6=BM 7=BR -- arbitrary but fixed, matched by oppositeHandleIndex()
+ // and adjustsX()/adjustsY() below.
+ QPointF handleNaturalPosition(int index, qreal w, qreal h)
+ {
+ switch (index)
+ {
+ case 0: return QPointF(0, 0);
+ case 1: return QPointF(w / 2, 0);
+ case 2: return QPointF(w, 0);
+ case 3: return QPointF(0, h / 2);
+ case 4: return QPointF(w, h / 2);
+ case 5: return QPointF(0, h);
+ case 6: return QPointF(w / 2, h);
+ case 7: return QPointF(w, h);
+ }
+ return QPointF();
+ }
+
+ int oppositeHandleIndex(int index)
+ {
+ static const int opposite[8] = {7, 6, 5, 4, 3, 2, 1, 0};
+ return opposite[index];
+ }
+
+ // Corners adjust both axes; edge midpoints adjust only the axis
+ // perpendicular to their own edge.
+ bool adjustsX(int index) { return index != 1 && index != 6; }
+ bool adjustsY(int index) { return index != 3 && index != 4; }
+}
+
+/**
+ @brief DiagramImageItem::cornerPosition
+ Matches QetShapeItem::cornerPoint()'s own slot convention exactly
+ (0=TL 1=TR 2=BR 3=BL), so the rotate math ported from there (see
+ dragRotateHandle()) lines up without needing its own re-derivation.
+*/
+QPointF DiagramImageItem::cornerPosition(int cornerIndex, qreal w, qreal h)
+{
+ switch (cornerIndex & 3)
+ {
+ case 0: return QPointF(0, 0);
+ case 1: return QPointF(w, 0);
+ case 2: return QPointF(w, h);
+ default: return QPointF(0, h);
+ }
+}
+
+/**
+ @brief DiagramImageItem::edgeMidpointPosition
+ Matches QetShapeItem::edgeMidpoint()'s own slot convention exactly
+ (0=N 1=E 2=S 3=W), for the same reason as cornerPosition() above.
+*/
+QPointF DiagramImageItem::edgeMidpointPosition(int edgeIndex, qreal w, qreal h)
+{
+ switch (edgeIndex & 3)
+ {
+ case 0: return QPointF(w / 2, 0);
+ case 1: return QPointF(w, h / 2);
+ case 2: return QPointF(w / 2, h);
+ default: return QPointF(0, h / 2);
+ }
+}
+
+/**
+ @brief DiagramImageItem::scaleOnlyOffset / scaleAndShearOffset
+ Verbatim ports of QetShapeItem's own identically-named helpers (see
+ their definitions there for the derivation) -- the offset of a local
+ point from the pivot, with scale (and, for the second, shear too)
+ applied but rotation deliberately left out. Used by
+ dragRotateHandle()/dragSkewHandle() to solve for rotation/skew
+ directly rather than through mapFromScene(), which would divide out
+ the very value being solved for.
+*/
+QPointF DiagramImageItem::scaleOnlyOffset(const QPointF &localPoint) const
+{
+ const QPointF offset = localPoint - m_transform.pivot;
+ return QPointF(offset.x() * m_transform.scaleX, offset.y() * m_transform.scaleY);
+}
+
+QPointF DiagramImageItem::scaleAndShearOffset(const QPointF &localPoint) const
+{
+ const QPointF scaled = scaleOnlyOffset(localPoint);
+ const qreal kx = qTan(qDegreesToRadians(m_transform.skewX));
+ const qreal ky = qTan(qDegreesToRadians(m_transform.skewY));
+ return QPointF(scaled.x() + kx * scaled.y(), scaled.y() + ky * scaled.x());
+}
+
+/**
+ @brief DiagramImageItem::handlePosition
+ Local-space position of handle at vector index `index`, for
+ whichever role it currently holds -- the single source both
+ rebuildHandles() and repositionHandles() read from, so the two can
+ never disagree about where a handle belongs.
+*/
+QPointF DiagramImageItem::handlePosition(int index) const
+{
+ const qreal w = pixmap_.width(), h = pixmap_.height();
+ const HandleRole role = m_handleRoles.at(index);
+ if (role == HandleRole::Resize)
+ return handleNaturalPosition(index, w, h);
+ if (role == HandleRole::Rotate)
+ return cornerPosition(index, w, h); // Rotate handles are always the first 4 in RotateSkew mode, so index IS the corner slot
+ if (role == HandleRole::SkewEdge)
+ return edgeMidpointPosition(index - 4, w, h); // ...and SkewEdge the next 4, offset by those same 4
+ return m_transform.pivot; // Pivot
+}
+
+/**
+ @brief DiagramImageItem::mousePressEvent
+ Clicking an already-selected image cycles its handle mode, exactly
+ matching QetShapeItem's own established convention for the same
+ gesture -- deliberately kept consistent rather than inventing a
+ different interaction just because this is a different class.
+*/
+void DiagramImageItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
+{
+ const bool wasAlreadySelected = isSelected();
+ event->ignore();
+ QetGraphicsItem::mousePressEvent(event);
+
+ if (event->button() == Qt::LeftButton)
+ {
+ if (wasAlreadySelected && isSelected())
+ toggleHandleMode();
+ event->accept();
+ }
+}
+
+/**
+ @brief DiagramImageItem::toggleHandleMode
+*/
+void DiagramImageItem::toggleHandleMode()
+{
+ prepareGeometryChange();
+ m_handleMode = (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size;
+ rebuildHandles();
+ refreshInteractionHints();
+}
+
+/**
+ @brief DiagramImageItem::nextHandleMode
+ @return whichever mode a click would switch to from here -- the
+ other one, since there are only two.
+*/
+DiagramImageItem::HandleMode DiagramImageItem::nextHandleMode() const
+{
+ return (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size;
+}
+
+/**
+ @brief DiagramImageItem::handleModeLabel
+*/
+QString DiagramImageItem::handleModeLabel(HandleMode mode)
+{
+ return (mode == HandleMode::Size) ? tr("redimensionner") : tr("pivoter/incliner");
+}
+
+/**
+ @brief DiagramImageItem::updateModeHint
+ Keeps the tooltip in sync with what the next click would do --
+ called on selection change (so it appears/disappears with the
+ handles themselves) and after every mode switch. Ported from
+ QetShapeItem's identical method: deliberately short, since this is
+ a tooltip, not documentation -- the fuller gesture/modifier
+ reference lives in the status bar instead (see
+ currentModeStatusHint(), hoverEnterEvent()).
+*/
+void DiagramImageItem::updateModeHint()
+{
+ setToolTip(isSelected()
+ ? tr("Cliquer : mode %1").arg(handleModeLabel(nextHandleMode()))
+ : QString());
+}
+
+/**
+ @brief DiagramImageItem::refreshInteractionHints
+ Keeps the tooltip text current, and -- if already being hovered --
+ immediately re-shows both the tooltip and the status bar hint
+ rather than leaving them stuck on whatever was true before. Ported
+ from QetShapeItem's identical method, for the identical reason: Qt
+ only re-evaluates a tooltip, and this class only re-shows the
+ status bar, when the cursor *moves* -- selecting an image (often
+ clicked while the mouse was already sitting on it) or cycling
+ handle modes (definitely clicked while sitting on it) both change
+ what should be shown without the cursor moving at all.
+*/
+void DiagramImageItem::refreshInteractionHints()
+{
+ updateModeHint();
+
+ if (!isHovered() || !isSelected())
+ return;
+
+ showStatusHint(currentModeStatusHint());
+}
+
+/**
+ @brief DiagramImageItem::currentModeStatusHint
+ One-line reference for whatever handles are visible right now,
+ shown in the status bar while hovering a selected image's body --
+ the modifier keys in particular (Ctrl, Shift) have no other visible
+ indication that they do anything at all. Also carries the same
+ "next mode" information as the tooltip, since the status bar has
+ room for the full picture in one place.
+*/
+QString DiagramImageItem::currentModeStatusHint() const
+{
+ QString hint = (m_handleMode == HandleMode::Size)
+ ? tr("Glisser un coin/bord : redimensionner (Ctrl = depuis le centre, Maj = conserver les proportions)")
+ : tr("Glisser un coin : pivoter (Maj = par pas de 15°) ; glisser un bord : incliner (Maj = par pas de 15°) ; point rouge : déplacer le centre de rotation");
+ hint += tr(" -- %1 : mode %2").arg(tr("Cliquer"), handleModeLabel(nextHandleMode()));
+ return hint;
+}
+
+/**
+ @brief DiagramImageItem::hoverEnterEvent
+*/
+void DiagramImageItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event)
+{
+ QetGraphicsItem::hoverEnterEvent(event);
+ refreshInteractionHints();
+}
+
+/**
+ @brief DiagramImageItem::hoverLeaveEvent
+*/
+void DiagramImageItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event)
+{
+ QetGraphicsItem::hoverLeaveEvent(event);
+ clearStatusHint();
+}
+
+/**
+ @brief DiagramImageItem::clearHandles
+*/
+void DiagramImageItem::clearHandles()
+{
+ if (!m_handler_vector.isEmpty())
+ {
+ qDeleteAll(m_handler_vector);
+ m_handler_vector.clear();
+ }
+ m_handleRoles.clear();
+}
+
+/**
+ @brief DiagramImageItem::rebuildHandles
+ A deliberately small switch compared to QetShapeItem's own version:
+ images have exactly one "shape" (a plain rectangle matching the
+ pixmap's natural size), so there's no shape-type branching to do --
+ just the two handle modes themselves.
+*/
+/**
+ @brief DiagramImageItem::colorForHandleRole / hintForHandleRole
+ Single source of truth for both rebuildHandles() (which sets each
+ handle's native tooltip) and sceneEventFilter()'s hover dispatch
+ (which shows the identical text in the status bar) -- keeping these
+ as two independently-maintained copies is exactly how they'd
+ quietly drift apart over time, as already happened once between
+ writing this pair.
+*/
+QColor DiagramImageItem::colorForHandleRole(HandleRole role)
+{
+ switch (role)
+ {
+ case HandleRole::Resize: return Qt::blue;
+ case HandleRole::Rotate: return Qt::darkGreen;
+ case HandleRole::SkewEdge: return QColor(255, 140, 0);
+ case HandleRole::Pivot: return Qt::red;
+ }
+ return Qt::blue;
+}
+
+QString DiagramImageItem::hintForHandleRole(HandleRole role)
+{
+ switch (role)
+ {
+ case HandleRole::Resize: return tr("Glisser : redimensionner (Maj = conserver les proportions, Ctrl = depuis le centre)");
+ case HandleRole::Rotate: return tr("Glisser : pivoter (Maj = par pas de 15°)");
+ case HandleRole::SkewEdge: return tr("Glisser : incliner (Maj = par pas de 15°)");
+ case HandleRole::Pivot: return tr("Glisser : déplacer le centre de rotation");
+ }
+ return QString();
+}
+
+void DiagramImageItem::rebuildHandles()
+{
+ clearHandles();
+
+ if (m_handleMode == HandleMode::Size)
+ {
+ for (int i = 0; i < 8; ++i)
+ m_handleRoles << HandleRole::Resize;
+ }
+ else // RotateSkew: 4 corners (rotate), then 4 edges (skew), then 1 pivot -- handlePosition() relies on this exact order
+ {
+ for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::Rotate;
+ for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::SkewEdge;
+ m_handleRoles << HandleRole::Pivot;
+ }
+
+ if (m_handleRoles.isEmpty() || !scene())
+ return;
+
+ QVector positions;
+ for (int i = 0; i < m_handleRoles.size(); ++i)
+ positions << handlePosition(i);
+
+ m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(positions), QETUtils::graphicsHandlerSize(this));
+
+ for (int i = 0; i < m_handler_vector.size(); ++i)
+ {
+ QetGraphicsHandlerItem *h = m_handler_vector.at(i);
+ h->setZValue(zValue() + 1);
+ h->setColor(colorForHandleRole(m_handleRoles.at(i)));
+ h->setToolTip(hintForHandleRole(m_handleRoles.at(i)));
+ h->setAcceptHoverEvents(true);
+ scene()->addItem(h);
+ h->installSceneEventFilter(this);
+ }
+}
+
+/**
+ @brief DiagramImageItem::repositionHandles
+ Moves the *existing* handle items to match current geometry, without
+ touching their identity -- safe to call on every frame of a live
+ drag, matching QetShapeItem's own repositionHandles()/rebuildHandles()
+ split for the identical reason.
+*/
+void DiagramImageItem::repositionHandles()
+{
+ if (m_handler_vector.isEmpty())
+ return;
+ if (m_handler_vector.size() != m_handleRoles.size())
+ {
+ rebuildHandles();
+ return;
+ }
+
+ for (int i = 0; i < m_handleRoles.size(); ++i)
+ m_handler_vector.at(i)->setPos(mapToScene(handlePosition(i)));
+}
+
+/**
+ @brief DiagramImageItem::sceneEventFilter
+ Dispatches handle interaction events -- structurally identical to
+ QetShapeItem::sceneEventFilter(), reused as a pattern rather than
+ shared as code, since the two classes' handle roles are different
+ enough (no path/polygon/arc concepts here at all) that sharing the
+ dispatcher itself would need an awkward, indirect abstraction for
+ very little actual code saved.
+*/
+bool DiagramImageItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event)
+{
+ if (watched->type() != QetGraphicsHandlerItem::Type)
+ return false;
+
+ QetGraphicsHandlerItem *qghi = qgraphicsitem_cast(watched);
+ const int index = m_handler_vector.indexOf(qghi);
+ if (index == -1)
+ return false;
+
+ if (event->type() == QEvent::GraphicsSceneMousePress)
+ {
+ handlerMousePressEvent(index, static_cast(event)->modifiers());
+ return true;
+ }
+ if (event->type() == QEvent::GraphicsSceneMouseMove)
+ {
+ handlerMouseMoveEvent(index, static_cast(event));
+ return true;
+ }
+ if (event->type() == QEvent::GraphicsSceneMouseRelease)
+ {
+ handlerMouseReleaseEvent(index);
+ return true;
+ }
+ if (event->type() == QEvent::GraphicsSceneHoverEnter)
+ {
+ // On top of the tooltip Qt already shows natively from the
+ // handle's own setToolTip() (see rebuildHandles()) -- returning
+ // false leaves that native tooltip handling alone, matching
+ // QetShapeItem's identical dual approach for its own handles.
+ showStatusHint(hintForHandleRole(m_handleRoles.at(index)));
+ return false;
+ }
+ if (event->type() == QEvent::GraphicsSceneHoverLeave)
+ {
+ clearStatusHint();
+ return false;
+ }
+ return false;
+}
+
+/**
+ @brief DiagramImageItem::showStatusHint
+*/
+void DiagramImageItem::showStatusHint(const QString &text) const
+{
+ if (text.isEmpty() || !diagram() || diagram()->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
+ editor->statusBar()->showMessage(text);
+}
+
+/**
+ @brief DiagramImageItem::clearStatusHint
+*/
+void DiagramImageItem::clearStatusHint() const
+{
+ if (!diagram() || diagram()->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
+ editor->statusBar()->clearMessage();
+}
+
+/**
+ @brief DiagramImageItem::handlerMousePressEvent
+ For a Resize handle, temporarily repositions the pivot for the
+ duration of this drag: to the OPPOSITE corner/edge by default (so
+ that one stays fixed, the usual convention), or to the CENTER if
+ Ctrl is held at the moment of the press (so the image grows
+ symmetrically from its middle instead). Decided once here, not
+ re-checked on every subsequent move -- mirroring the identical fix
+ already made for shape creation, and for the identical reason:
+ continuously re-checking made releasing the modifier mid-drag
+ revert a decision that felt already made, rather than a deliberate
+ choice made once at the start.
+
+ Verified numerically before relying on this: with the pivot fixed
+ at whichever reference point applies, solving for a new scale that
+ puts the dragged handle at the mouse automatically keeps that
+ reference point exactly where it was, with zero drift, across all 8
+ handles and a rotated, non-uniformly-scaled starting transform.
+ compensatedPositionForNewPivot() is what keeps this repositioning
+ itself invisible -- without it, temporarily moving the pivot would
+ visibly jump the image the instant the drag starts.
+*/
+void DiagramImageItem::handlerMousePressEvent(int index, Qt::KeyboardModifiers mods)
+{
+ m_vector_index = index;
+ m_original_pos = pos();
+ m_original_transform = m_transform;
+
+ if (m_handleRoles.at(index) == HandleRole::Resize)
+ {
+ m_resizeCenterAnchored = mods & Qt::ControlModifier;
+ const QPointF referencePoint = m_resizeCenterAnchored
+ ? QPointF(pixmap_.width() / 2.0, pixmap_.height() / 2.0)
+ : handleNaturalPosition(oppositeHandleIndex(index), pixmap_.width(), pixmap_.height());
+ const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, referencePoint, m_transform.linearPart());
+ m_deferHandleReposition = true;
+ m_transform.pivot = referencePoint;
+ // QGraphicsObject::setPos(), not the plain setPos() -- see
+ // setPivot()'s identical comment for why: QetGraphicsItem's
+ // override snaps to the grid, which would corrupt this exact
+ // compensation the same way it did there.
+ QGraphicsObject::setPos(newPos);
+ setTransform(m_transform.toMatrix());
+ m_deferHandleReposition = false;
+ }
+}
+
+/**
+ @brief DiagramImageItem::handlerMouseMoveEvent
+*/
+void DiagramImageItem::handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent *event)
+{
+ const HandleRole role = m_handleRoles.at(index);
+
+ if (role == HandleRole::Resize)
+ dragResize(index, mapFromScene(event->scenePos()), event->modifiers());
+ else if (role == HandleRole::Rotate)
+ dragRotateHandle(index, event->scenePos(), event->modifiers()); // index IS the corner slot (0-3): Rotate handles are always first
+ else if (role == HandleRole::SkewEdge)
+ dragSkewHandle(index - 4, event->scenePos(), event->modifiers()); // offset by the 4 preceding Rotate handles
+ else if (role == HandleRole::Pivot)
+ dragPivot(mapFromScene(event->scenePos()));
+}
+
+/**
+ @brief DiagramImageItem::handlerMouseReleaseEvent
+ Two responsibilities, in order: first, Resize's own pivot cleanup
+ (the pivot was temporarily relocated to the opposite corner or
+ center in handlerMousePressEvent() purely to make the drag math
+ simple, and has to move back now that the drag is over, via
+ resetPivotToBoundingRectCenter(), which itself handles the position
+ compensation that reset needs) -- but only when the pivot isn't
+ user-customized, matching dragPivot()'s own comment for why.
+ Second -- and previously entirely missing -- building the actual
+ undo command for whatever changed during this drag: every drag
+ method above mutates m_transform directly and calls setTransform()
+ immediately, live, with no undo tracking of its own at all, exactly
+ like QetShapeItem's identical handlerMouseMoveEvent()/dragResize()
+ pair. QetShapeItem's own handlerMouseReleaseEvent() is where that
+ gets reconciled into a single undo step per role, comparing
+ m_original_transform/m_original_pos (captured at press time) against
+ the now-already-applied live values -- this is a direct port of
+ that same pattern for this class's own, smaller role set. Pushing a
+ command whose redo() sets a property to what it's already live at
+ is intentional, not wasted: redo() runs once immediately, as a
+ harmless no-op, so the stack has a correct entry for Ctrl+Z without
+ the live drag needing any awareness of undo at all. For Pivot
+ specifically, chaining "pos" before "rawPivot" -- not the
+ compensating "pivot" -- means undoing simply restores both to their
+ exact recorded values, without a second, redundant compensation
+ needing to run and (correctly, but confusingly) arrive at the same
+ place a longer way around.
+*/
+void DiagramImageItem::handlerMouseReleaseEvent(int index)
+{
+ const HandleRole role = m_handleRoles.at(index);
+
+ if (role == HandleRole::Resize && !m_pivotIsCustom)
+ resetPivotToBoundingRectCenter();
+
+ if (diagram())
+ {
+ QUndoCommand *undo = nullptr;
+
+ switch (role)
+ {
+ case HandleRole::Resize:
+ if (!qFuzzyCompare(m_transform.scaleX, m_original_transform.scaleX))
+ undo = new QPropertyUndoCommand(this, "scaleFactorX", m_original_transform.scaleX, m_transform.scaleX);
+ if (!qFuzzyCompare(m_transform.scaleY, m_original_transform.scaleY))
+ {
+ if (undo)
+ new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY, undo);
+ else
+ undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY);
+ }
+ 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);
+ break;
+
+ case HandleRole::SkewEdge:
+ if (!qFuzzyCompare(m_transform.skewX, m_original_transform.skewX))
+ 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);
+ break;
+
+ case HandleRole::Pivot:
+ if (m_transform.pivot != m_original_transform.pivot)
+ {
+ undo = new QUndoCommand(tr("Déplacer le centre de rotation d'une image"));
+ new QPropertyUndoCommand(this, "pos", m_original_pos, pos(), undo);
+ new QPropertyUndoCommand(this, "rawPivot", m_original_transform.pivot, m_transform.pivot, undo);
+ }
+ break;
+ }
+
+ if (undo)
+ {
+ if (undo->text().isEmpty())
+ undo->setText(tr("Modifier une image"));
+ diagram()->undoStack().push(undo);
+ }
+ }
+
+ m_vector_index = -1;
+}
+
+/**
+ @brief DiagramImageItem::dragResize
+ Verified numerically (all 8 handles, a rotated and non-uniformly
+ scaled starting transform) before writing this: undoing only
+ rotation from the target -- never scale, since that's the very
+ value being solved for -- is what avoids the feedback-loop mistake
+ a full mapFromScene()-based solve would fall into (mapFromScene()
+ divides out the CURRENT scale, not the new one, since it doesn't
+ know a new one is being computed at all).
+*/
+void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods)
+{
+ const QPointF pivotScene = pos() + m_transform.pivot;
+ QTransform undoRotation;
+ undoRotation.rotate(-m_transform.rotation);
+ const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene);
+
+ const QPointF handleNatural = handleNaturalPosition(index, pixmap_.width(), pixmap_.height());
+ const qreal handleDx = handleNatural.x() - m_transform.pivot.x();
+ const qreal handleDy = handleNatural.y() - m_transform.pivot.y();
+
+ qreal newScaleX = m_transform.scaleX;
+ qreal newScaleY = m_transform.scaleY;
+ if (adjustsX(index) && qAbs(handleDx) > 1e-6)
+ newScaleX = postScaleOffset.x() / handleDx;
+ if (adjustsY(index) && qAbs(handleDy) > 1e-6)
+ newScaleY = postScaleOffset.y() / handleDy;
+
+ if (mods & Qt::ShiftModifier)
+ {
+ // Preserve the ORIGINAL aspect ratio (captured at press time in
+ // m_original_transform), not force scaleX==scaleY -- an image
+ // that wasn't square to begin with should stay proportional to
+ // itself, not become literally square the moment Shift is held.
+ const qreal originalRatio = (qFuzzyIsNull(m_original_transform.scaleY))
+ ? 1.0 : m_original_transform.scaleX / m_original_transform.scaleY;
+ if (adjustsX(index) && !adjustsY(index))
+ newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio);
+ else if (adjustsY(index) && !adjustsX(index))
+ newScaleX = newScaleY * originalRatio;
+ else
+ {
+ // Corner handle: whichever axis moved further from its own
+ // starting value, in relative terms, drives the other.
+ const qreal relX = qAbs(newScaleX / m_original_transform.scaleX);
+ const qreal relY = qAbs(newScaleY / m_original_transform.scaleY);
+ if (relX > relY)
+ newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio);
+ else
+ newScaleX = newScaleY * originalRatio;
+ }
+ }
+
+ // A pixmap can't meaningfully render at zero or negative scale --
+ // clamped well above zero rather than letting it vanish or silently
+ // flip (negative scale is a mirror, and mirroring is already its
+ // own deliberate, explicit context-menu action, not something a
+ // fast drag past the opposite edge should trigger by accident).
+ const qreal MIN_SCALE = 0.02;
+ if (newScaleX < MIN_SCALE) newScaleX = MIN_SCALE;
+ if (newScaleY < MIN_SCALE) newScaleY = MIN_SCALE;
+
+ m_transform.scaleX = newScaleX;
+ m_transform.scaleY = newScaleY;
+ setTransform(m_transform.toMatrix());
+ repositionHandles();
+ emit transformChanged();
+}
+
+/**
+ @brief DiagramImageItem::dragRotateHandle
+ Verified numerically before writing this (all 4 corners, WITH
+ nonzero skew already present -- scaleAndShearOffset() is what
+ makes the reference direction correctly account for that): the
+ handle ends up pointing exactly at the mouse's angle from the
+ pivot, and the pivot itself never moves. Ported from
+ QetShapeItem::dragRotateHandle() -- see that copy's own comment for
+ why this is a plain assignment rather than a recurrence (computing
+ the angle via mapFromScene() would divide out the current rotation
+ and turn each frame's result into a feedback loop).
+*/
+void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
+{
+ const QPointF scenePivot = pos() + m_transform.pivot;
+ const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x()));
+
+ const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height()));
+ const qreal angleReference = qRadiansToDegrees(qAtan2(reference.y(), reference.x()));
+
+ qreal angle = angleMouse - angleReference;
+ if (mods & Qt::ShiftModifier)
+ angle = qRound(angle / 15.0) * 15.0;
+
+ m_transform.rotation = angle;
+ setTransform(m_transform.toMatrix());
+ repositionHandles();
+ emit transformChanged();
+}
+
+/**
+ @brief DiagramImageItem::dragSkewHandle
+ Verified numerically before writing this (all 4 edges, WITH nonzero
+ rotation already present): recovers the exact skew angle that would
+ put the edge handle at a given scene position, for both skewX
+ (N/S edges) and skewY (E/W edges). Ported from
+ QetShapeItem::dragSkewHandle() -- see that copy's own comment for
+ the closed-form derivation (solved for the one skew component being
+ dragged, holding everything else fixed, since going through
+ mapFromScene() would divide out the very value being solved for).
+*/
+void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
+{
+ const QPointF pivot = m_transform.pivot;
+ const QPointF Q = scaleOnlyOffset(edgeMidpointPosition(edgeIndex, pixmap_.width(), pixmap_.height()));
+
+ const qreal rad = qDegreesToRadians(m_transform.rotation);
+ const qreal c = qCos(rad), s = qSin(rad);
+ const QPointF targetRel = scenePos - pos() - pivot;
+ const QPointF M(targetRel.x() * c + targetRel.y() * s,
+ -targetRel.x() * s + targetRel.y() * c);
+
+ qreal degrees;
+ if (edgeIndex == 0 || edgeIndex == 2) // N/S edge -> skewX
+ {
+ if (qFuzzyIsNull(Q.y())) return;
+ degrees = qRadiansToDegrees(qAtan((M.x() - Q.x()) / Q.y()));
+ if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0;
+ m_transform.skewX = degrees;
+ }
+ else // E/W edge -> skewY
+ {
+ if (qFuzzyIsNull(Q.x())) return;
+ degrees = qRadiansToDegrees(qAtan((M.y() - Q.y()) / Q.x()));
+ if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0;
+ m_transform.skewY = degrees;
+ }
+
+ setTransform(m_transform.toMatrix());
+ repositionHandles();
+ emit transformChanged();
+}
+
+/**
+ @brief DiagramImageItem::dragPivot
+ Marks the pivot as user-customized, the same way QetShapeItem's own
+ pivot handle does -- so a later resize (see handlerMouseReleaseEvent())
+ doesn't silently snap it back to the bounding-rect center the
+ instant the user deliberately moved it somewhere else.
+*/
+void DiagramImageItem::dragPivot(const QPointF &localPos)
+{
+ m_pivotIsCustom = true;
+ setPivot(localPos);
+ repositionHandles();
+}
+
+/**
+ @brief DiagramImageItem::restoreAspectRatio
+ Context-menu action, the direct answer to "restoration of aspect
+ ratio" from the original wishlist: makes scaleY match scaleX,
+ keeping the current width fixed. The natural aspect ratio is
+ already fully accounted for by the pixmap's own width/height (a
+ uniform scale, by definition, can never distort it) -- the
+ distortion is entirely scaleX and scaleY disagreeing with each
+ other, so undoing it is exactly "make them agree", not a
+ computation involving pixmap_'s own dimensions at all. An earlier
+ version of this multiplied by the natural height/width ratio a
+ second time, which double-counted it and left the image still
+ visibly distorted, just less obviously so.
+*/
+void DiagramImageItem::restoreAspectRatio()
+{
+ if (!diagram() || diagram()->isReadOnly())
+ return;
+
+ if (qFuzzyCompare(m_transform.scaleY, m_transform.scaleX))
+ return;
+
+ auto *undo = new QPropertyUndoCommand(this, "scaleFactorY", m_transform.scaleY, m_transform.scaleX);
+ undo->setText(tr("Restaurer les proportions d'une image"));
+ diagram()->undoStack().push(undo);
+}
+
+/**
+ @brief DiagramImageItem::itemChange
+*/
+QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant &value)
+{
+ if (change == ItemSelectedHasChanged)
+ {
+ if (value.toBool())
+ rebuildHandles();
+ else
+ {
+ prepareGeometryChange();
+ clearHandles();
+ m_handleMode = HandleMode::Size;
+ }
+ refreshInteractionHints();
+ }
+ else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged)
+ {
+ if (!m_deferHandleReposition)
+ repositionHandles();
+ }
+ else if (change == ItemSceneHasChanged)
+ {
+ if (!scene())
+ setSelected(false);
+ }
+
+ return QGraphicsItem::itemChange(change, value);
+}
+
+/**
+ @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.
+ @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
+*/
+QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList &colors, int tolerance)
+{
+ const QPixmap cropped = cropRect == base.rect() ? base : base.copy(cropRect);
+ if (colors.isEmpty())
+ return cropped;
+ return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors, tolerance));
}
/**
@@ -153,8 +1134,91 @@ bool DiagramImageItem::fromXml(const QDomElement &e)
pixmap.loadFromData(array);
setPixmap(pixmap);
- setScale(e.attribute("size").toDouble());
- setRotation(e.attribute("rotation").toDouble());
+ // Falls back to treating the loaded result as its own base, with no
+ // remembered crop or colours -- correct both for a genuinely plain
+ // image and for a file saved before these features existed.
+ // Overwritten below if the file actually does carry this
+ // information.
+ 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();
+ 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()));
+ }
+ }
+
+ const QDomElement cropElement = e.firstChildElement("crop");
+ bool hasCrop = !cropElement.isNull();
+ if (hasCrop)
+ {
+ m_crop_rect = QRect(
+ cropElement.attribute("x").toInt(),
+ cropElement.attribute("y").toInt(),
+ cropElement.attribute("w").toInt(),
+ cropElement.attribute("h").toInt());
+ }
+
+ // Present, and only meaningful, whenever either of the above is --
+ // the base pixmap on its own, with no crop or colours to apply to
+ // it, wouldn't mean anything.
+ if (hasColors || hasCrop)
+ {
+ const QDomElement baseElement = e.firstChildElement("image_base");
+ if (!baseElement.isNull())
+ {
+ const QByteArray baseArray = QByteArray::fromBase64(baseElement.text().toLatin1());
+ QPixmap basePixmap;
+ if (basePixmap.loadFromData(baseArray))
+ m_base_pixmap = basePixmap;
+ }
+ // m_crop_rect may still refer to a saved file's base image, not
+ // pixmap (used as a fallback above only when nothing better is
+ // available) -- clamp it to whatever base actually ended up
+ // loaded, so an inconsistent or hand-edited file can't produce
+ // an out-of-bounds crop rect later.
+ m_crop_rect = m_crop_rect.intersected(m_base_pixmap.rect());
+ if (m_crop_rect.isEmpty())
+ m_crop_rect = m_base_pixmap.rect();
+ }
+
+ // Baseline: the plain "rotation"/"size" attributes, understood by
+ // every version of this code, past and future -- size applied
+ // uniformly to both axes, since a single float can't say otherwise.
+ // Overwritten below if the file actually carries the richer
+ // element (independent scaleX/scaleY, and/or a custom
+ // pivot), the same two-tier fallback QetShapeItem's own fromXml()
+ // already uses for its identical element.
+ m_transform.rotation = e.attribute("rotation").toDouble();
+ m_transform.scaleX = e.attribute("size").toDouble();
+ m_transform.scaleY = m_transform.scaleX;
+ m_transform.pivot = boundingRect().center();
+ m_pivotIsCustom = false;
+
+ const QDomElement transformElement = e.firstChildElement("transform");
+ if (!transformElement.isNull())
+ {
+ m_transform.rotation = transformElement.attribute("rotation", "0").toDouble();
+ m_transform.skewX = transformElement.attribute("skewX", "0").toDouble();
+ m_transform.skewY = transformElement.attribute("skewY", "0").toDouble();
+ m_transform.scaleX = transformElement.attribute("scaleX", "1").toDouble();
+ m_transform.scaleY = transformElement.attribute("scaleY", "1").toDouble();
+ m_transform.pivot = QPointF(transformElement.attribute("pivotX", "0").toDouble(),
+ transformElement.attribute("pivotY", "0").toDouble());
+ m_pivotIsCustom = true;
+ }
+ setTransform(m_transform.toMatrix());
+
//We directly call setPos from QGraphicsObject, because QetGraphicsItem will snap to grid
QGraphicsObject::setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble());
setZValue(e.attribute("z", QString::number(this->zValue())).toDouble());
@@ -174,8 +1238,20 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const
result.setAttribute("x", QString::number(pos().x()));
result.setAttribute("y", QString::number(pos().y()));
result.setAttribute("z", QString::number(this->zValue()));
- result.setAttribute("rotation", QString::number(QET::correctAngle(rotation())));
- result.setAttribute("size", QString::number(scale()));
+ // m_transform.rotation/scaleX, not rotation()/scale(): those are
+ // QGraphicsItem's own convenience properties, and this class no
+ // longer ever calls their setters at all -- it uses setTransform()
+ // directly now, the only way to represent independent scaleX/scaleY
+ // at all, so rotation()/scale() would always read back their
+ // defaults (0 and 1) regardless of the image's actual, visible
+ // state. "size" keeps scaleX specifically (not some average of
+ // scaleX/scaleY) as its best-effort value for older code that only
+ // ever understood a single uniform scale -- the element
+ // below is what any code that understands both axes independently
+ // should prefer instead, exactly mirroring how QetShapeItem's own
+ // otherwise-identical element already works.
+ result.setAttribute("rotation", QString::number(QET::correctAngle(m_transform.rotation)));
+ result.setAttribute("size", QString::number(m_transform.scaleX));
result.setAttribute("is_movable", bool(is_movable_));
//write the pixmap in the xml element after he was been transformed to base64
@@ -186,5 +1262,372 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const
QDomText base64 = document.createTextNode(array.toBase64());
result.appendChild(base64);
+ // Full-fidelity transform, only written when it actually carries
+ // something the rotation/size attributes above can't already
+ // express -- exactly the same convention QetShapeItem's own
+ // identical element already uses, for the same reason:
+ // this keeps a plain, never-resized-independently image's saved
+ // file byte-for-byte unchanged.
+ const bool hasRichTransform = !qFuzzyCompare(m_transform.scaleX, m_transform.scaleY)
+ || !qFuzzyIsNull(m_transform.skewX) || !qFuzzyIsNull(m_transform.skewY) || m_pivotIsCustom;
+ if (hasRichTransform)
+ {
+ QDomElement transformElement = document.createElement("transform");
+ transformElement.setAttribute("rotation", QString::number(m_transform.rotation));
+ transformElement.setAttribute("skewX", QString::number(m_transform.skewX));
+ transformElement.setAttribute("skewY", QString::number(m_transform.skewY));
+ transformElement.setAttribute("scaleX", QString::number(m_transform.scaleX));
+ transformElement.setAttribute("scaleY", QString::number(m_transform.scaleY));
+ transformElement.setAttribute("pivotX", QString::number(m_transform.pivot.x()));
+ transformElement.setAttribute("pivotY", QString::number(m_transform.pivot.y()));
+ result.appendChild(transformElement);
+ }
+
+ // Only written when there's actually something to remember -- a
+ // plain image that's never been cropped or had transparency applied
+ // shouldn't carry this extra weight at all. pixmap_ above already
+ // reflects the current result on its own, so older code (or a file
+ // that never used either feature) reads back exactly what it
+ // always did; this is purely additional context for the dialogs'
+ // own memory, letting them be reopened non-destructively.
+ const bool hasCrop = (m_crop_rect != m_base_pixmap.rect());
+ const bool hasColors = !m_transparent_colors.isEmpty();
+
+ if (hasColors)
+ {
+ QDomElement colorsElement = document.createElement("transparent_colors");
+ colorsElement.setAttribute("tolerance", m_transparent_tolerance);
+ for (const QColor &color : m_transparent_colors)
+ {
+ QDomElement colorElement = document.createElement("color");
+ colorElement.setAttribute("r", color.red());
+ colorElement.setAttribute("g", color.green());
+ colorElement.setAttribute("b", color.blue());
+ colorsElement.appendChild(colorElement);
+ }
+ result.appendChild(colorsElement);
+ }
+
+ if (hasCrop)
+ {
+ QDomElement cropElement = document.createElement("crop");
+ cropElement.setAttribute("x", m_crop_rect.x());
+ cropElement.setAttribute("y", m_crop_rect.y());
+ cropElement.setAttribute("w", m_crop_rect.width());
+ cropElement.setAttribute("h", m_crop_rect.height());
+ result.appendChild(cropElement);
+ }
+
+ if (hasCrop || hasColors)
+ {
+ QByteArray baseArray;
+ QBuffer baseBuffer(&baseArray);
+ baseBuffer.open(QIODevice::ReadWrite);
+ m_base_pixmap.save(&baseBuffer, "PNG");
+ QDomElement baseElement = document.createElement("image_base");
+ baseElement.appendChild(document.createTextNode(baseArray.toBase64()));
+ result.appendChild(baseElement);
+ }
+
return(result);
}
+
+/**
+ @brief DiagramImageItem::contextMenuEvent
+ @param event
+*/
+void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
+{
+ if (!diagram())
+ {
+ QetGraphicsItem::contextMenuEvent(event);
+ return;
+ }
+
+ if (diagram()->selectedItems().isEmpty())
+ this->setSelected(true);
+
+ if (isSelected() && scene()->selectedItems().size() == 1)
+ {
+ DiagramView *d_view = nullptr;
+ for (QGraphicsView *view : diagram()->views())
+ {
+ if (view->isActiveWindow())
+ {
+ d_view = dynamic_cast(view);
+ if (d_view)
+ continue;
+ }
+ }
+
+ if (d_view)
+ {
+ QScopedPointer menu(new QMenu());
+
+ QAction *replace = menu.data()->addAction(tr("Remplacer l'image..."));
+ connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage);
+
+ QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente..."));
+ connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor);
+
+ QAction *cropAction = menu.data()->addAction(tr("Rogner..."));
+ connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop);
+
+ QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
+ QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
+ 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();
+ menu.data()->addActions(d_view->contextMenuActions());
+ menu.data()->exec(event->screenPos());
+ event->accept();
+ return;
+ }
+ }
+
+ QetGraphicsItem::contextMenuEvent(event);
+}
+
+/**
+ @brief DiagramImageItem::replaceImage
+ Context-menu action: swaps the underlying pixmap for one loaded from
+ a new file, keeping position, rotation and scale untouched -- only
+ pixmap_ changes, everything else about how this item sits on the
+ diagram is left exactly as it was. Reuses the same file dialog
+ filter and error handling as DiagramEventAddImage::openDialog(), so
+ picking a replacement looks and behaves like picking a new image did
+ when this item was first inserted.
+*/
+void DiagramImageItem::replaceImage()
+{
+ if (!diagram() || diagram()->isReadOnly())
+ return;
+
+ QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
+ const QString fileName = QFileDialog::getOpenFileName(
+ parentWidget, tr("Selectionner une image..."),
+ QETApp::pictureDir(), tr("Image Files (*.png *.jpg *.jpeg *.bmp *.svg)"));
+ if (fileName.isEmpty())
+ return;
+
+ QImage image(fileName);
+ if (image.isNull())
+ {
+ QMessageBox::critical(parentWidget, tr("Erreur"), tr("Impossible de charger l'image."));
+ return;
+ }
+
+ const QPixmap oldPixmap = pixmap_;
+ const QPixmap newPixmap = QPixmap::fromImage(image);
+
+ // A wholesale replacement, not an edit of the existing image -- the
+ // new picture has nothing to do with whatever colours were picked
+ // or region was cropped for the old one, so this starts that
+ // memory fresh rather than carrying over choices that would no
+ // longer make sense.
+ m_base_pixmap = newPixmap;
+ m_crop_rect = newPixmap.rect();
+ m_transparent_colors.clear();
+
+ auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
+ undo->setText(tr("Remplacer une image"));
+ diagram()->undoStack().push(undo);
+}
+
+/**
+ @brief DiagramImageItem::mirror
+ Context-menu action: flips the pixmap itself, not a transform --
+ unlike QetShapeItem::mirror(), which has rotation/skew to contend
+ with, there's no equivalent linear-transform decomposition needed
+ here: the bitmap is flipped once, directly, and stays flipped
+ regardless of whatever rotation is applied on top afterward.
+*/
+void DiagramImageItem::mirror(bool horizontal)
+{
+ if (!diagram() || diagram()->isReadOnly())
+ return;
+
+ const QPixmap oldPixmap = pixmap_;
+ const QTransform flip = horizontal ? QTransform(-1, 0, 0, 1, 0, 0) : QTransform(1, 0, 0, -1, 0, 0);
+ const QPixmap newPixmap = pixmap_.transformed(flip);
+
+ // The base is flipped the same way, to stay in sync with pixmap_ --
+ // but the picked-colours list itself is left untouched: the actual
+ // colour values don't change when the image is mirrored, only their
+ // positions, so whatever was already keyed transparent should stay
+ // remembered and still apply correctly to the flipped version.
+ m_base_pixmap = m_base_pixmap.transformed(flip);
+
+ // m_crop_rect, unlike the colour list, DOES need to change: it's
+ // defined in terms of positions within the base, and those
+ // positions just moved. Width/height and the other axis are
+ // untouched -- only the axis being flipped needs its edge mirrored
+ // (dimensions are unaffected by the flip, so it doesn't matter
+ // whether this reads m_base_pixmap's size from before or after the
+ // assignment above).
+ if (horizontal)
+ m_crop_rect = QRect(m_base_pixmap.width() - m_crop_rect.left() - m_crop_rect.width(),
+ m_crop_rect.top(), m_crop_rect.width(), m_crop_rect.height());
+ else
+ m_crop_rect = QRect(m_crop_rect.left(), m_base_pixmap.height() - m_crop_rect.top() - m_crop_rect.height(),
+ m_crop_rect.width(), m_crop_rect.height());
+
+ auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
+ undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image"));
+ diagram()->undoStack().push(undo);
+}
+
+/**
+ @brief DiagramImageItem::setTransparentColor
+ Context-menu action: opens ImageTransparentColorDialog against
+ m_base_pixmap (the pristine source), pre-populated with whatever
+ colours and tolerance were remembered from a previous session --
+ both problems fixed together, since they had the same root cause:
+ passing pixmap_ (the already colour-keyed result) as if it were the
+ source, with nowhere to remember which colours produced it. Applies
+ the result the same way replaceImage() and mirror() do, through the
+ "pixmap" property, so undo/redo stays consistent across all three;
+ m_base_pixmap itself is deliberately left untouched here, since this
+ action only ever changes which colours are keyed out of it, not the
+ source those colours are keyed out of.
+*/
+/**
+ @brief DiagramImageItem::setTransparentColor
+ Context-menu action: opens ImageTransparentColorDialog against the
+ CROPPED base (m_base_pixmap.copy(m_crop_rect)), not the full,
+ uncropped original -- picking a colour from a region that's already
+ been permanently cropped away would be picking a colour that isn't
+ even part of the image anymore. Pre-populated with whatever colours
+ and tolerance were remembered from a previous session. Applies the
+ result the same way replaceImage() and mirror() do, through the
+ "pixmap" property, so undo/redo stays consistent across all three;
+ m_base_pixmap and m_crop_rect are deliberately left untouched here,
+ since this action only ever changes which colours are keyed out,
+ never the source region they're keyed out of.
+*/
+void DiagramImageItem::setTransparentColor()
+{
+ if (!diagram() || diagram()->isReadOnly())
+ return;
+
+ 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);
+ 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"));
+ diagram()->undoStack().push(undo);
+}
+
+/**
+ @brief DiagramImageItem::crop
+ Context-menu action: opens ImageCropDialog against pixmap_ (the
+ current, already colour-keyed display, so cropping is WYSIWYG
+ against whatever is actually visible), then applies the chosen
+ rectangle to both pixmap_ and m_base_pixmap together -- kept in
+ sync the same way mirror() keeps them in sync, since cropping is a
+ permanent, geometric change to the image's own content, unlike
+ setTransparentColor()'s non-destructive colour keying.
+
+ pos() also needs adjusting, not just pixmap_: setPixmap() (called
+ via the "pixmap" undo command below) recomputes
+ transformOriginPoint() from the new, smaller boundingRect(), but
+ pos() itself is untouched by that -- without fixing it up here too,
+ the surviving content would visually jump to wherever local (0,0)
+ happens to land after shrinking, rather than staying exactly where
+ it already was. Chained into one undo step together with the pixmap
+ change, since undoing a crop has to restore both, or the restored
+ (larger) image ends up in the wrong place.
+*/
+/**
+ @brief DiagramImageItem::crop
+ Context-menu action: opens ImageCropDialog against m_base_pixmap
+ (the true, uncropped original), pre-populated with whatever crop
+ rectangle was chosen in a previous session -- re-editable, not
+ destructive: nothing about the original content is ever discarded,
+ only which region of it is currently being shown, exactly the same
+ principle setTransparentColor() already follows for its own choices.
+ Recomputes pixmap_ via computeDisplayPixmap() so any already-picked
+ transparent colours are correctly re-applied to the newly-cropped
+ region, rather than lost (the crop dialog itself knows nothing
+ about them).
+
+ pos() also needs adjusting, not just pixmap_: setPixmap() (called
+ via the "pixmap" undo command below) recomputes
+ transformOriginPoint() from the new boundingRect(), but pos() itself
+ is untouched by that -- without fixing it up here too, the
+ surviving content would visually jump to wherever local (0,0) ends
+ up after the crop rect changes, rather than staying exactly where
+ it already was. This has to work whether this is the first crop
+ ever applied or an adjustment of an existing one, so the position
+ math is always done relative to the CURRENT crop rect (m_crop_rect,
+ before it's updated below) -- when there's no previous crop, that's
+ simply the whole base, which is what the very first version of this
+ method assumed unconditionally.
+
+ Chained into one undo step together with the pixmap change, since
+ undoing a crop has to restore both, or the restored (larger) image
+ ends up in the wrong place.
+*/
+void DiagramImageItem::crop()
+{
+ if (!diagram() || diagram()->isReadOnly())
+ return;
+
+ QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first();
+ ImageCropDialog dialog(m_base_pixmap, m_crop_rect, parentWidget);
+ if (dialog.exec() != QDialog::Accepted)
+ return;
+
+ const QRect newCropRect = dialog.cropRect();
+ if (newCropRect.isEmpty() || newCropRect == m_crop_rect)
+ return; // nothing actually changed
+
+ // newCropRect is in m_base_pixmap's own coordinates; converting its
+ // center into the CURRENT local space (pixmap_'s own coordinates,
+ // i.e. relative to the OLD m_crop_rect's own top-left) before
+ // mapping to the scene through the transform that's still active
+ // right now, prior to anything below changing it.
+ const QPointF newCropCenterInCurrentLocal = QRectF(newCropRect).center() - QPointF(m_crop_rect.topLeft());
+ const QPointF cropCenterScene = mapToScene(newCropCenterInCurrentLocal);
+ const QPointF oldPos = pos();
+
+ const QPixmap oldPixmap = pixmap_;
+ const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors, m_transparent_tolerance);
+ m_crop_rect = newCropRect;
+
+ // boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by
+ // reading the actual implementation, not assumed) -- so the new
+ // origin point can be derived directly from newPixmap here. Unlike
+ // before this class gained a proper transform, setPixmap() no
+ // longer manages the pivot automatically at all (see its own
+ // comment for why) -- crop() now has to set it explicitly itself,
+ // chained into the same undo step as the pixmap and position
+ // changes, since all three genuinely change together here.
+ const QPointF oldPivot = m_transform.pivot;
+ const QPointF newOriginPoint = QRectF(newPixmap.rect()).center();
+ const QPointF newPos = cropCenterScene - newOriginPoint;
+
+ auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
+ undo->setText(tr("Rogner une image"));
+ new QPropertyUndoCommand(this, "pos", oldPos, newPos, undo);
+ new QPropertyUndoCommand(this, "rawPivot", oldPivot, newOriginPoint, undo);
+ m_pivotIsCustom = false;
+ diagram()->undoStack().push(undo);
+}
diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h
index f983eb3b5..f59d419e1 100644
--- a/sources/qetgraphicsitem/diagramimageitem.h
+++ b/sources/qetgraphicsitem/diagramimageitem.h
@@ -19,9 +19,16 @@
#define DIAGRAM_IMAGE_ITEM_H
#include "qetgraphicsitem.h"
+#include "shapetransform.h"
+
+#include
+#include
+#include
class QDomElement;
class QDomDocument;
+class QGraphicsSceneContextMenuEvent;
+class QetGraphicsHandlerItem;
/**
This class represents a selectable, movable and editable image on a
@@ -30,6 +37,23 @@ class QDomDocument;
*/
class DiagramImageItem : public QetGraphicsItem {
Q_OBJECT
+ Q_PROPERTY(QPixmap pixmap READ pixmap WRITE setPixmap NOTIFY pixmapChanged)
+ Q_PROPERTY(qreal scaleFactorX READ scaleFactorX WRITE setScaleFactorX NOTIFY transformChanged)
+ Q_PROPERTY(qreal scaleFactorY READ scaleFactorY WRITE setScaleFactorY NOTIFY transformChanged)
+ Q_PROPERTY(qreal rotationAngle READ rotationAngle WRITE setRotationAngle NOTIFY transformChanged)
+ Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged)
+ Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged)
+ Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged)
+ // A second, deliberately non-compensating property on the SAME
+ // underlying value -- setPivot() (above) intentionally adjusts
+ // pos() to keep the image visually in place, which is exactly
+ // wrong for crop()'s own undo chain: crop() already computes the
+ // correct final pos() itself (accounting for the crop, not just
+ // the pivot move) and pushes it as its own separate "pos" command,
+ // so a chained "pivot" step going through the compensating setter
+ // would silently overwrite that already-correct pos() a second
+ // time. rawPivot exists solely for that one caller.
+ Q_PROPERTY(QPointF rawPivot READ pivot WRITE setPivotRaw NOTIFY transformChanged)
// constructors, destructor
public:
@@ -40,6 +64,19 @@ class DiagramImageItem : public QetGraphicsItem {
// attributes
public:
enum { Type = UserType + 1007 };
+
+ // A deliberately smaller, image-specific set than QetShapeItem's own
+ // HandleMode/HandleRole: images have no path/polygon/arc concepts at
+ // all, so reusing those enums directly would pull in a great deal of
+ // irrelevant baggage for no benefit. RotateSkew's math (rotate,
+ // skew, and their shared scaleAndShearOffset()/scaleOnlyOffset()
+ // helpers) mirrors QetShapeItem's own identical formulas -- adapted
+ // to this class's own, simpler 4-corner/4-edge/1-pivot handle set,
+ // not shared code, since the two classes' broader handle roles
+ // differ too much (no path/polygon/arc concepts here) for sharing
+ // the dispatcher itself to be worth an indirect abstraction.
+ enum class HandleMode { Size, RotateSkew };
+ enum class HandleRole { Resize, Rotate, SkewEdge, Pivot };
// methods
public:
@@ -54,13 +91,108 @@ class DiagramImageItem : public QetGraphicsItem {
virtual QDomElement toXml(QDomDocument &) const;
void editProperty() override;
void setPixmap(const QPixmap &pixmap);
+ QPixmap pixmap() const { return pixmap_; }
QRectF boundingRect() const override;
QString name() const override;
-
+
+ qreal scaleFactorX() const { return m_transform.scaleX; }
+ qreal scaleFactorY() const { return m_transform.scaleY; }
+ void setScaleFactorX(qreal factor);
+ void setScaleFactorY(qreal factor);
+ qreal rotationAngle() const { return m_transform.rotation; }
+ void setRotationAngle(qreal angle);
+ qreal skewX() const { return m_transform.skewX; }
+ void setSkewX(qreal skew);
+ qreal skewY() const { return m_transform.skewY; }
+ void setSkewY(qreal skew);
+ QPointF pivot() const { return m_transform.pivot; }
+ void setPivot(const QPointF &pivot);
+ void setPivotRaw(const QPointF &pivot);
+
+ signals:
+ void pixmapChanged();
+ void transformChanged();
+
protected:
void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override;
-
+ void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override;
+ void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
+ void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
+ void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
+ bool sceneEventFilter(QGraphicsItem *watched, QEvent *event) override;
+ QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
+
+ private:
+ void replaceImage();
+ void mirror(bool horizontal);
+ void setTransparentColor();
+ void crop();
+ void restoreAspectRatio();
+ static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList &colors, int tolerance);
+
+ void toggleHandleMode();
+ HandleMode nextHandleMode() const;
+ static QString handleModeLabel(HandleMode mode);
+ void updateModeHint();
+ void refreshInteractionHints();
+ QString currentModeStatusHint() const;
+ void rebuildHandles();
+ void repositionHandles();
+ void clearHandles();
+ void resetPivotToBoundingRectCenter();
+ void handlerMousePressEvent(int index, Qt::KeyboardModifiers mods);
+ void handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent *event);
+ void handlerMouseReleaseEvent(int index);
+ void dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods);
+ void dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods);
+ void dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods);
+ void dragPivot(const QPointF &localPos);
+ QPointF scaleOnlyOffset(const QPointF &localPoint) const;
+ QPointF scaleAndShearOffset(const QPointF &localPoint) const;
+ QPointF handlePosition(int index) const;
+ static QPointF cornerPosition(int cornerIndex, qreal w, qreal h);
+ static QPointF edgeMidpointPosition(int edgeIndex, qreal w, qreal h);
+ static QColor colorForHandleRole(HandleRole role);
+ static QString hintForHandleRole(HandleRole role);
+ void showStatusHint(const QString &text) const;
+ void clearStatusHint() const;
+
protected:
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()
+ // 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 m_transparent_colors;
+ int m_transparent_tolerance = 10;
+
+ // Independent scaleX/scaleY here is the actual point of this whole
+ // member: QGraphicsItem::scale() is a single, uniform float, which
+ // is exactly why an image could never break its own aspect ratio
+ // before this. skewX/skewY exist in the struct but are never
+ // written by anything below -- deliberately deferred, not an
+ // oversight (see the .cpp for why).
+ ShapeTransform m_transform;
+ bool m_pivotIsCustom = false;
+ HandleMode m_handleMode = HandleMode::Size;
+ QVector m_handler_vector;
+ QVector m_handleRoles;
+ int m_vector_index = -1;
+ QPointF m_original_pos; // scene position at the start of a resize/rotate/pivot drag, for Escape-to-cancel
+ ShapeTransform m_original_transform;
+ bool m_deferHandleReposition = false; // see setPivot()'s comment
+ bool m_resizeCenterAnchored = false; // decided once, at press time -- see handlerMousePressEvent()'s comment for why, mirroring the identical fix already made for shape creation
};
#endif
diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp
index 1fe149ab3..a744cf772 100644
--- a/sources/qetgraphicsitem/qetshapeitem.cpp
+++ b/sources/qetgraphicsitem/qetshapeitem.cpp
@@ -25,16 +25,28 @@
#include "../diagramview.h"
#include "../qet.h"
#include "../qeticons.h"
+#include "../qetapp.h"
+#include "../qetdiagrameditor.h"
#include "../qetxml.h"
#include "../ui/shapegraphicsitempropertieswidget.h"
#include "../utils/qetutils.h"
+#include "../undocommand/promoteshapecommand.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
/**
@brief QetShapeItem::QetShapeItem
Constructor of shape item. point 1 and 2 must be in scene coordinate
@param p1 first point
@param p2 second point
- @param type type of item (line, rectangle, ellipse)
+ @param type type of item (line, rectangle, ellipse, polygon, path)
@param parent parent item
*/
QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem *parent) :
@@ -48,6 +60,7 @@ QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem
setFlags(QGraphicsItem::ItemIsMovable | QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemSendsGeometryChanges);
setAcceptHoverEvents(true);
m_pen.setStyle(Qt::SolidLine);
+ m_transform.pivot = localRect().center();
//ensure handlers are always above this item
connect(this, &QetShapeItem::zChanged, [this]()
{
@@ -61,7 +74,6 @@ QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem
m_remove_point = new QAction(tr("Supprimer ce point"), this);
m_remove_point->setIcon(QET::Icons::Remove);
connect(m_remove_point, &QAction::triggered, this, &QetShapeItem::removePoint);
-
}
QetShapeItem::~QetShapeItem()
@@ -115,6 +127,21 @@ void QetShapeItem::setP2(const QPointF &P2)
prepareGeometryChange();
m_P2 = P2;
}
+ else
+ {
+ return; // nothing actually changed
+ }
+ // setP2() is what drives the live "second point under the cursor"
+ // preview while a new shape is being drawn (see
+ // diagrameventaddshape.cpp) -- every other geometry setter already
+ // keeps the pivot following the center until it's customized; this
+ // one was missing it, which is why a freshly click-and-dragged shape
+ // could end up with its pivot frozen at the very first click point
+ // (a degenerate, zero-size starting rect/line) instead of the
+ // shape's actual center once it was drawn out.
+ if (!m_pivotIsCustom)
+ resetPivotToBoundingRectCenter();
+ emit geometryChanged();
}
/**
@@ -129,7 +156,10 @@ bool QetShapeItem::setLine(const QLineF &line)
prepareGeometryChange();
m_P1 = line.p1();
m_P2 = line.p2();
- adjustHandlerPos();
+ if (!m_pivotIsCustom)
+ resetPivotToBoundingRectCenter();
+ repositionHandles();
+ emit geometryChanged();
return true;
}
@@ -146,7 +176,10 @@ bool QetShapeItem::setRect(const QRectF &rect)
prepareGeometryChange();
m_P1 = rect.topLeft();
m_P2 = rect.bottomRight();
- adjustHandlerPos();
+ if (!m_pivotIsCustom)
+ resetPivotToBoundingRectCenter();
+ repositionHandles();
+ emit geometryChanged();
return true;
}
@@ -166,18 +199,22 @@ bool QetShapeItem::setPolygon(const QPolygonF &polygon)
}
prepareGeometryChange();
m_polygon = polygon;
- adjustHandlerPos();
+ if (!m_pivotIsCustom)
+ resetPivotToBoundingRectCenter();
+ repositionHandles();
+ emit geometryChanged();
return true;
}
/**
@brief QetShapeItem::setClosed
- Close this item, have effect only if this item is a polygon.
+ Close this item -- has effect for Polygon and Path only (the two
+ shape types with a genuine open/closed distinction at all).
@param close
*/
void QetShapeItem::setClosed(bool close)
{
- if (m_shapeType == Polygon && close != m_closed)
+ if ((m_shapeType == Polygon || m_shapeType == Path) && close != m_closed)
{
prepareGeometryChange();
m_closed = close;
@@ -189,7 +226,7 @@ void QetShapeItem::setXRadius(qreal X)
{
m_xRadius = X;
update();
- adjustHandlerPos();
+ repositionHandles();
emit XRadiusChanged();
}
@@ -197,10 +234,204 @@ void QetShapeItem::setYRadius(qreal Y)
{
m_yRadius = Y;
update();
- adjustHandlerPos();
+ repositionHandles();
emit YRadiusChanged();
}
+/**
+ @brief QetShapeItem::setRotation
+ @param degrees
+ One property per ShapeTransform scalar: every handle (and the
+ properties panel) touches exactly one of these, never the matrix
+ directly. See shapetransform.h for why the matrix itself is only ever
+ built from these, never mutated by hand.
+*/
+void QetShapeItem::setRotation(qreal degrees)
+{
+ if (qFuzzyCompare(m_transform.rotation, degrees)) return;
+ prepareGeometryChange();
+ m_transform.rotation = degrees;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void QetShapeItem::setSkewX(qreal degrees)
+{
+ if (qFuzzyCompare(m_transform.skewX, degrees)) return;
+ prepareGeometryChange();
+ m_transform.skewX = degrees;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void QetShapeItem::setSkewY(qreal degrees)
+{
+ if (qFuzzyCompare(m_transform.skewY, degrees)) return;
+ prepareGeometryChange();
+ m_transform.skewY = degrees;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void QetShapeItem::setScaleFactorX(qreal factor)
+{
+ if (qFuzzyCompare(m_transform.scaleX, factor)) return;
+ prepareGeometryChange();
+ m_transform.scaleX = factor;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+void QetShapeItem::setScaleFactorY(qreal factor)
+{
+ if (qFuzzyCompare(m_transform.scaleY, factor)) return;
+ prepareGeometryChange();
+ m_transform.scaleY = factor;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+}
+
+/**
+ @brief QetShapeItem::setPivot
+ Move the pivot point. pos() is adjusted at the same time so the shape
+ never visibly jumps -- see compensatedPositionForNewPivot() in
+ shapetransform.h for why that adjustment is needed at all.
+ @param newPivot in local coordinates
+*/
+void QetShapeItem::setPivot(const QPointF &newPivot)
+{
+ if (m_transform.pivot == newPivot) return;
+ const QPointF newPos = compensatedPositionForNewPivot(
+ pos(), m_transform.pivot, newPivot, m_transform.linearPart());
+ prepareGeometryChange();
+ m_transform.pivot = newPivot;
+ // Apply both related updates, then reposition handles exactly once
+ // using the fully-updated state. Without this guard, setTransform()
+ // alone would trigger a reposition using the new transform but the
+ // still-old pos() (or vice versa if the calls were swapped), which is
+ // a real, if usually imperceptible, inconsistent intermediate state --
+ // removing it outright is cheaper than reasoning about whether it's
+ // ever visible.
+ m_deferHandleReposition = true;
+ setTransform(m_transform.toMatrix());
+ // QetGraphicsItem::setPos() (our own base class) silently re-snaps its
+ // argument to the grid before applying it -- appropriate for an
+ // ordinary user drag of the whole shape, but wrong here: newPos is an
+ // exact compensation value derived from rotation trigonometry, so it's
+ // essentially never grid-aligned, and snapping it introduces real
+ // rounding error on every single frame of a pivot drag. Over a
+ // multi-frame drag that accumulates into a clearly visible drift of
+ // the whole rectangle -- confirmed by simulation: reproducing the
+ // grid-snap here produced tens of units of drift over a normal drag;
+ // calling Qt's own, non-snapping setPos() directly produced exactly
+ // zero. Grid-snapping the *pivot itself* (done earlier, in
+ // handlerMouseMoveEvent, on the mouse position) is still exactly
+ // right -- it's only this internal, precision-critical compensation
+ // step that must bypass it.
+ QGraphicsItem::setPos(newPos);
+ m_deferHandleReposition = false;
+ repositionHandles();
+ emit transformChanged();
+}
+
+void QetShapeItem::resetPivotToBoundingRectCenter()
+{
+ m_pivotIsCustom = false;
+ setPivot(localRect().center());
+}
+
+/**
+ @brief QetShapeItem::enableNodeEditMode
+ Switches into NodeEdit mode, so every node's control handles (and
+ their tangent guide lines drawn in paint()) become visible -- the
+ same visual feedback normal editing already gets via the context
+ menu's node-kind actions, made available to the pen tool too so a
+ node's handles are visible *as they're being dragged into
+ existence*, not only afterward.
+*/
+void QetShapeItem::enableNodeEditMode()
+{
+ if (m_shapeType != Path)
+ return;
+ m_handleMode = HandleMode::NodeEdit;
+ rebuildHandles();
+}
+
+/**
+ @brief QetShapeItem::setStartAngle / setEndAngle
+ Only meaningful for Ellipse. Dragging one endpoint onto the other
+ (span within 5 degrees of a full turn) snaps back to a full ellipse --
+ this is the whole answer to "how do I turn an arc back into a closed
+ ellipse": there is no separate Arc type to convert out of.
+*/
+// Dragging one endpoint until it nearly touches the other closes the
+// ellipse back up -- checked on the *geometric* (mod 360) proximity of
+// the two angles, not the raw stored span, since dragArcEndpoint()
+// deliberately keeps the raw span continuous/unwrapped (it can legally
+// exceed +-360 during a drag) rather than snapping it into a fixed
+// range on every frame.
+static bool anglesGeometricallyAdjacent(qreal span)
+{
+ qreal wrapped = std::fmod(span, 360.0);
+ if (wrapped < 0) wrapped += 360.0;
+ return wrapped < 5.0 || wrapped > 355.0;
+}
+
+void QetShapeItem::setStartAngle(qreal degrees)
+{
+ if (qFuzzyCompare(m_startAngle, degrees)) return;
+ prepareGeometryChange();
+ m_startAngle = degrees;
+ if (anglesGeometricallyAdjacent(spanAngle())) { m_startAngle = 0; m_endAngle = 360; }
+ repositionHandles();
+ emit arcChanged();
+}
+
+void QetShapeItem::setEndAngle(qreal degrees)
+{
+ if (qFuzzyCompare(m_endAngle, degrees)) return;
+ prepareGeometryChange();
+ m_endAngle = degrees;
+ if (anglesGeometricallyAdjacent(spanAngle())) { m_startAngle = 0; m_endAngle = 360; }
+ repositionHandles();
+ emit arcChanged();
+}
+
+void QetShapeItem::setArcClosure(ArcClosure closure)
+{
+ if (m_arcClosure == closure) return;
+ prepareGeometryChange();
+ m_arcClosure = closure;
+ emit arcChanged();
+}
+
+/**
+ @brief QetShapeItem::setPathNodes
+ Replace the node list of a Path shape. Interactive editing of anchors
+ and control handles both go through the handle roles built by
+ rebuildHandles() (PathAnchor always visible; PathControlIn/Out for
+ the active node once double-click enters node-edit mode) -- this
+ setter itself is just the plain data replacement underneath that.
+*/
+void QetShapeItem::setPathNodes(const QVector &nodes)
+{
+ prepareGeometryChange();
+ m_nodes = nodes;
+ if (!m_pivotIsCustom)
+ resetPivotToBoundingRectCenter();
+ // Always a full rebuild, not just a reposition: unlike the drag
+ // handlers (dragPathAnchor, dragPathControlHandle), which mutate
+ // m_nodes in place and are called from a handle's own mouse-move
+ // event -- where destroying that handle mid-gesture would be a real
+ // problem -- this setter is only ever called from outside that
+ // pipeline (currently: the pen tool, adding a new node with every
+ // click). The node *count* routinely changes here, and
+ // repositionHandles() has no way to notice that on its own; it only
+ // moves whatever handles already exist.
+ rebuildHandles();
+ emit geometryChanged();
+}
+
/**
@brief QetShapeItem::pointCount
@return the number of point in the polygon
@@ -240,7 +471,6 @@ void QetShapeItem::removePoints(int number)
i++;
prepareGeometryChange();
m_polygon.pop_back();
- setTransformOriginPoint(boundingRect().center());
} while (i < number);
}
@@ -251,14 +481,49 @@ void QetShapeItem::removePoints(int number)
*/
QRectF QetShapeItem::boundingRect() const
{
- return shape().boundingRect().adjusted(-6, -6, 6, 6);
+ QRectF rect = shape().boundingRect().adjusted(-6, -6, 6, 6);
+
+ if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit)
+ {
+ for (const PathNode &n : m_nodes)
+ {
+ if (n.inHandle)
+ rect |= QRectF(n.anchor, n.anchor + *n.inHandle).normalized().adjusted(-6, -6, 6, 6);
+ if (n.outHandle)
+ rect |= QRectF(n.anchor, n.anchor + *n.outHandle).normalized().adjusted(-6, -6, 6, 6);
+ }
+ }
+
+ return rect;
}
/**
- @brief QetShapeItem::shape
- @return the shape of this item
+ @brief QetShapeItem::localRect
+ The rect used for corner/edge handle placement and for arc/radius
+ math. For Polygon and Path this is the vertex bounding box; for
+ everything else it is simply the P1/P2 rect, as before.
*/
-QPainterPath QetShapeItem::shape() const
+QRectF QetShapeItem::localRect() const
+{
+ if (m_shapeType == Polygon)
+ return m_polygon.boundingRect();
+ if (m_shapeType == Path)
+ {
+ QPolygonF anchors;
+ for (const PathNode &n : m_nodes) anchors << n.anchor;
+ return anchors.boundingRect();
+ }
+ return QRectF(m_P1, m_P2).normalized();
+}
+
+/**
+ @brief QetShapeItem::outline
+ The raw, unstroked path for the current type, in local coordinates.
+ Shared by shape() (which strokes it for hit-testing/selection) and
+ paint() (which draws it directly) so the two can never disagree about
+ what the shape actually looks like.
+*/
+QPainterPath QetShapeItem::outline() const
{
QPainterPath path;
@@ -268,29 +533,85 @@ QPainterPath QetShapeItem::shape() const
path.moveTo(m_P1);
path.lineTo(m_P2);
break;
+
case Rectangle:
- path.addRoundedRect(
- QRectF(m_P1, m_P2),
- m_xRadius,
- m_yRadius);
+ path.addRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
break;
+
case Ellipse:
- path.addEllipse(QRectF(m_P1, m_P2));
+ {
+ const QRectF r(QRectF(m_P1, m_P2));
+ if (isFullEllipse())
+ {
+ path.addEllipse(r);
+ }
+ else if (m_arcClosure == Pie)
+ {
+ path.moveTo(r.center());
+ path.arcTo(r, m_startAngle, spanAngle());
+ path.closeSubpath();
+ }
+ else
+ {
+ path.arcMoveTo(r, m_startAngle);
+ path.arcTo(r, m_startAngle, spanAngle());
+ if (m_arcClosure == Chord)
+ path.closeSubpath();
+ }
break;
+ }
+
case Polygon:
path.addPolygon(m_polygon);
if (m_closed) {
path.closeSubpath();
}
break;
+
+ case Path:
+ if (!m_nodes.isEmpty())
+ {
+ path.moveTo(m_nodes.first().anchor);
+ for (int i = 1; i < m_nodes.size(); ++i)
+ {
+ const PathNode &prev = m_nodes.at(i - 1);
+ const PathNode &cur = m_nodes.at(i);
+ if (prev.outHandle || cur.inHandle)
+ path.cubicTo(
+ prev.anchor + prev.outHandle.value_or(QPointF()),
+ cur.anchor + cur.inHandle.value_or(QPointF()),
+ cur.anchor);
+ else
+ path.lineTo(cur.anchor);
+ }
+ if (m_closed && m_nodes.size() > 1)
+ {
+ const PathNode &last = m_nodes.last();
+ const PathNode &first = m_nodes.first();
+ if (last.outHandle || first.inHandle)
+ path.cubicTo(
+ last.anchor + last.outHandle.value_or(QPointF()),
+ first.anchor + first.inHandle.value_or(QPointF()),
+ first.anchor);
+ path.closeSubpath();
+ }
+ }
+ break;
}
+ return path;
+}
+
+/**
+ @brief QetShapeItem::shape
+ @return the shape of this item
+*/
+QPainterPath QetShapeItem::shape() const
+{
QPainterPathStroker pps;
pps.setWidth(m_hovered? m_pen.widthF()+10 : m_pen.widthF());
pps.setJoinStyle(Qt::RoundJoin);
- path = pps.createStroke(path);
-
- return (path);
+ return pps.createStroke(outline());
}
/**
@@ -325,15 +646,88 @@ void QetShapeItem::paint(
painter -> restore ();
}
- switch (m_shapeType)
+ painter->drawPath(outline());
+
+ // Segment midpoint markers: a small, distinct diamond at the middle
+ // of every segment -- straight or already curved -- shown only in
+ // NodeEdit mode. Purely a discoverability aid: a straight run
+ // between two Corner nodes otherwise gives no visual hint at all
+ // that it's draggable (see mousePressEvent()'s curve-drag
+ // detection), which is exactly what made that interaction easy to
+ // miss. Deliberately *not* a real handle -- the actual drag already
+ // works from anywhere along the segment via nearestPathSegment(),
+ // not just this exact point, and turning the marker into its own
+ // discrete hit target would only narrow that back down. Diamond
+ // shape and a muted colour distinguish it at a glance from the
+ // round, brighter anchor/control dots, which are real handles.
+ // Drawn before the guide lines/handles on purpose, so those stay
+ // visually on top of this rather than the reverse.
+ if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit)
{
- case Line: painter->drawLine(QLineF(m_P1, m_P2)); break;
- case Rectangle: painter->drawRoundedRect(QRectF(m_P1, m_P2),
- m_xRadius,
- m_yRadius); break;
- case Ellipse: painter->drawEllipse(QRectF(m_P1, m_P2)); break;
- case Polygon: m_closed ? painter->drawPolygon(m_polygon)
- : painter->drawPolyline(m_polygon); break;
+ const int count = m_nodes.size();
+ const int segments = m_closed ? count : count - 1;
+ if (segments > 0)
+ {
+ painter->save();
+ QPen markerPen(QColor(180, 120, 40));
+ markerPen.setWidthF(1.2);
+ markerPen.setCosmetic(true);
+ painter->setPen(markerPen);
+ painter->setBrush(QColor(255, 210, 130, 200));
+
+ for (int i = 0; i < segments; ++i)
+ {
+ const PathNode &a = m_nodes.at(i);
+ const PathNode &b = m_nodes.at((i + 1) % count);
+ const QPointF p0 = a.anchor;
+ const QPointF p1 = a.anchor + a.outHandle.value_or(QPointF());
+ const QPointF p2 = b.anchor + b.inHandle.value_or(QPointF());
+ const QPointF p3 = b.anchor;
+
+ const qreal t = 0.5, u = 0.5;
+ const QPointF mid = u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3;
+
+ const qreal r = 3.5; // half-diagonal, in local units
+ QPolygonF diamond;
+ diamond << QPointF(mid.x(), mid.y() - r)
+ << QPointF(mid.x() + r, mid.y())
+ << QPointF(mid.x(), mid.y() + r)
+ << QPointF(mid.x() - r, mid.y());
+ painter->drawPolygon(diamond);
+ }
+ painter->restore();
+ }
+ }
+
+ // Tangent guide lines: connects each visible control handle back to
+ // its anchor, for every node that has any -- deliberately not
+ // filtered down to "just one node" (see the header's HandleMode
+ // comment): for the small, decorative curves this editor actually
+ // deals with, seeing every handle at once removes a click's worth of
+ // friction per node, and is worth the trade-off even if it would get
+ // busy on a much larger, hand-traced path.
+ // Cosmetic on purpose, unlike the shape's own stroke: this is a UI
+ // aid, not artwork, so it should stay a constant screen width
+ // regardless of zoom or the shape's own skew -- the same reasoning
+ // that already makes the handle dots themselves ItemIgnoresTransformations.
+ if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit)
+ {
+ for (const PathNode &n : m_nodes)
+ {
+ if (!n.inHandle && !n.outHandle)
+ continue;
+ painter->save();
+ QPen guidePen(QColor(120, 120, 120));
+ guidePen.setStyle(Qt::DashLine);
+ guidePen.setWidthF(1.0);
+ guidePen.setCosmetic(true);
+ painter->setPen(guidePen);
+ if (n.inHandle)
+ painter->drawLine(n.anchor, n.anchor + *n.inHandle);
+ if (n.outHandle)
+ painter->drawLine(n.anchor, n.anchor + *n.outHandle);
+ painter->restore();
+ }
}
painter->restore();
@@ -347,6 +741,7 @@ void QetShapeItem::paint(
void QetShapeItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event)
{
m_hovered = true;
+ refreshInteractionHints();
QetGraphicsItem::hoverEnterEvent(event);
}
@@ -358,20 +753,132 @@ void QetShapeItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event)
void QetShapeItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event)
{
m_hovered = false;
+ clearStatusHint();
QetGraphicsItem::hoverLeaveEvent(event);
}
+/**
+ @brief QetShapeItem::mousePressEvent
+ A left click on an *already selected* shape (without having dragged)
+ toggles between the Size and RotateSkew handle sets -- the same
+ convention LibreOffice Draw and PowerPoint use. A click that performs
+ the selection itself does not toggle, so selecting a shape always
+ starts in Size mode.
+*/
void QetShapeItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
+ // Grabbing the curve itself (not a handle -- those are separate
+ // QetGraphicsHandlerItems and take precedence automatically, since
+ // Qt only delivers this event here when the click missed all of
+ // them) reshapes the segment under the cursor -- but only once the
+ // press actually turns into a drag. A plain click here (press+release
+ // with no real movement) is exactly the same gesture that cycles the
+ // handle mode elsewhere, and NodeEdit mode's own click-on-the-curve
+ // hit-test matches almost any click that landed on the shape at all
+ // -- so deciding "reshape vs. cycle" at press time would make it
+ // impossible to ever click onward to RotateSkew once in NodeEdit.
+ // Deferred to mouseMoveEvent/mouseReleaseEvent instead, below.
+ if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit && event->button() == Qt::LeftButton)
+ {
+ const auto hit = nearestPathSegment(event->pos());
+ if (hit.first >= 0)
+ {
+ const int count = m_nodes.size();
+ const PathNode &a = m_nodes.at(hit.first);
+ const PathNode &b = m_nodes.at((hit.first + 1) % count);
+ m_curveDragSegment = hit.first;
+ m_curveDragT = hit.second;
+ m_curveDragOriginalP1 = a.anchor + a.outHandle.value_or(QPointF());
+ m_curveDragOriginalP2 = b.anchor + b.inHandle.value_or(QPointF());
+ m_curveDragPressPos = event->pos();
+ m_curveDragEngaged = false;
+ m_old_nodes = m_nodes;
+ event->accept();
+ return;
+ }
+ }
+
+ const bool wasAlreadySelected = isSelected();
event->ignore();
QetGraphicsItem::mousePressEvent(event);
- if (event->button() == Qt::LeftButton) {
- switchResizeMode();
+ if (event->button() == Qt::LeftButton)
+ {
+ if (wasAlreadySelected && isSelected())
+ toggleHandleMode();
event->accept();
}
}
+/**
+ @brief QetShapeItem::mouseMoveEvent
+ Only ever does something different from the base class while a
+ curve-segment drag (started in mousePressEvent above) is pending or
+ active; otherwise this is exactly QetGraphicsItem's own whole-shape-
+ move handling (grid-snapped drag, multi-selection movement via
+ diagram()->elementsMover()), untouched.
+*/
+void QetShapeItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (m_curveDragSegment >= 0)
+ {
+ if (!m_curveDragEngaged)
+ {
+ if (QLineF(m_curveDragPressPos, event->pos()).length() < 3.0)
+ return; // still just a (so far) plain click -- wait and see, don't reshape yet
+ m_curveDragEngaged = true;
+ }
+
+ QPointF scenePos = event->scenePos();
+ if (!(event->modifiers() & Qt::ControlModifier))
+ scenePos = Diagram::snapToGrid(scenePos);
+ dragCurveSegment(m_curveDragSegment, m_curveDragT, mapFromScene(scenePos));
+ event->accept();
+ return;
+ }
+ QetGraphicsItem::mouseMoveEvent(event);
+}
+
+/**
+ @brief QetShapeItem::mouseReleaseEvent
+ If the press in mousePressEvent never turned into a real drag, this
+ was just a plain click -- cycle the handle mode, exactly like a click
+ anywhere else on an already-selected shape would. Otherwise commit
+ the curve-drag's undo entry, reusing the same generic before/after
+ XML snapshot mechanism as every other Path edit. With no curve-drag
+ pending at all, this defers entirely to QetGraphicsItem's own release
+ handling (which ends the whole-shape-move gesture via elementsMover()).
+*/
+void QetShapeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (m_curveDragSegment >= 0)
+ {
+ if (m_curveDragEngaged)
+ {
+ if (m_nodes != m_old_nodes && diagram())
+ {
+ const QVector after = m_nodes;
+ m_nodes = m_old_nodes;
+ const QDomElement before = snapshotXml();
+ m_nodes = after;
+ const QDomElement afterXml = snapshotXml();
+ auto *undo = new PromoteShapeCommand(this, before, afterXml);
+ undo->setText(tr("Déformer une courbe"));
+ diagram()->undoStack().push(undo);
+ }
+ }
+ else
+ {
+ toggleHandleMode();
+ }
+ m_curveDragSegment = -1;
+ m_curveDragEngaged = false;
+ event->accept();
+ return;
+ }
+ QetGraphicsItem::mouseReleaseEvent(event);
+}
+
/**
@brief QetShapeItem::itemChange
@param change
@@ -384,8 +891,8 @@ QVariant QetShapeItem::itemChange(QGraphicsItem::GraphicsItemChange change,
if (change == ItemSelectedHasChanged)
{
if (value.toBool() == true) {
- //If this is selected, wa add handlers.
- addHandler();
+ //If this is selected, we add handlers.
+ rebuildHandles();
}
else //Else this is deselected, we remove handlers
{
@@ -393,12 +900,22 @@ QVariant QetShapeItem::itemChange(QGraphicsItem::GraphicsItemChange change,
{
qDeleteAll(m_handler_vector);
m_handler_vector.clear();
+ m_handleRoles.clear();
+ m_handleSlot.clear();
}
- m_resize_mode = 1;
+ // Same reasoning as toggleHandleMode(): resetting the mode
+ // changes boundingRect()'s coverage, so Qt needs to be told
+ // before it happens, not after -- this is exactly the path
+ // that left a stale guide line behind when deselecting a
+ // Path with a far-dragged handle still active.
+ prepareGeometryChange();
+ m_handleMode = HandleMode::Size;
}
+ refreshInteractionHints();
}
- else if (change == ItemPositionHasChanged) {
- adjustHandlerPos();
+ else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) {
+ if (!m_deferHandleReposition)
+ repositionHandles();
}
else if (change == ItemSceneHasChanged)
{
@@ -431,19 +948,38 @@ bool QetShapeItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event)
{
if(event->type() == QEvent::GraphicsSceneMousePress) //Click
{
- handlerMousePressEvent();
+ handlerMousePressEvent(m_vector_index);
return true;
}
else if(event->type() == QEvent::GraphicsSceneMouseMove) //Move
{
- handlerMouseMoveEvent(static_cast(event));
+ handlerMouseMoveEvent(m_vector_index, static_cast(event));
return true;
}
else if (event->type() == QEvent::GraphicsSceneMouseRelease) //Release
{
- handlerMouseReleaseEvent();
+ handlerMouseReleaseEvent(m_vector_index);
return true;
}
+ else if (event->type() == QEvent::GraphicsSceneHoverEnter)
+ {
+ // Handle-specific status bar text, on top of the
+ // tooltip Qt shows natively from the handle's own
+ // setToolTip() (see rebuildHandles()) -- returning
+ // false leaves that native tooltip handling alone.
+ showStatusHint(handleRoleTooltip(m_handleRoles.value(m_vector_index), m_handleSlot.value(m_vector_index)));
+ return false;
+ }
+ else if (event->type() == QEvent::GraphicsSceneHoverLeave)
+ {
+ // Falls back to the shape's own general hint (if the
+ // cursor is still over the shape's body overall)
+ // rather than clearing outright -- leaving one
+ // handle's small hit area shouldn't blank the status
+ // bar if you're still hovering the shape itself.
+ refreshInteractionHints();
+ return false;
+ }
}
}
}
@@ -459,7 +995,10 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
{
m_context_menu_pos = event->pos();
- if (m_shapeType == QetShapeItem::Polygon)
+ const bool canConvertToPath = (m_shapeType == Rectangle || m_shapeType == Ellipse);
+
+ if (m_shapeType == QetShapeItem::Polygon || m_shapeType == QetShapeItem::Path
+ || m_shapeType == QetShapeItem::Line || canConvertToPath)
{
if (diagram()->selectedItems().isEmpty()) {
this->setSelected(true);
@@ -483,20 +1022,94 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
if (d_view)
{
QScopedPointer menu(new QMenu());
- menu.data()->addAction(m_insert_point);
- if (m_handler_vector.count() > 2)
+ if (m_shapeType == QetShapeItem::Polygon)
{
- for (QetGraphicsHandlerItem *qghi : m_handler_vector)
+ menu.data()->addAction(m_insert_point);
+
+ if (m_handler_vector.count() > 2)
{
- if (qghi->contains(qghi->mapFromScene(event->scenePos())))
+ for (QetGraphicsHandlerItem *qghi : m_handler_vector)
{
- menu.data()->addAction(m_remove_point);
- break;
+ if (qghi->contains(qghi->mapFromScene(event->scenePos())))
+ {
+ menu.data()->addAction(m_remove_point);
+ break;
+ }
}
}
}
+ if (m_shapeType == QetShapeItem::Path && !m_nodes.isEmpty())
+ {
+ // Nearest node to the click, not "whichever tiny
+ // handle you managed to hit exactly" -- the latter
+ // is finicky for a right-click (no drag feedback
+ // to correct your aim), and silently found nothing
+ // once NodeEdit mode added PathControlIn/Out
+ // handles right next to the anchor, which this
+ // menu never checked for.
+ const QPointF localClick = mapFromScene(event->scenePos());
+ int nearest = 0;
+ qreal bestDist = -1;
+ for (int i = 0; i < m_nodes.size(); ++i)
+ {
+ const QPointF d = m_nodes.at(i).anchor - localClick;
+ const qreal dist = d.x() * d.x() + d.y() * d.y();
+ if (bestDist < 0 || dist < bestDist) { bestDist = dist; nearest = i; }
+ }
+
+ const std::pair segmentHit = nearestPathSegment(localClick);
+ if (segmentHit.first >= 0)
+ {
+ const int seg = segmentHit.first;
+ const qreal t = segmentHit.second;
+ QAction *insertAct = menu.data()->addAction(tr("Ajouter un point"));
+ connect(insertAct, &QAction::triggered, this, [this, seg, t]() { insertPathPoint(seg, t); });
+ }
+
+ QMenu *nodeMenu = menu.data()->addMenu(tr("Nœud le plus proche"));
+ QAction *toSmooth = nodeMenu->addAction(tr("Lisse"));
+ QAction *toSymmetric = nodeMenu->addAction(tr("Symétrique"));
+ QAction *toCorner = nodeMenu->addAction(tr("Anguleux"));
+
+ auto *group = new QActionGroup(nodeMenu);
+ for (QAction *a : {toSmooth, toSymmetric, toCorner})
+ {
+ a->setCheckable(true);
+ a->setActionGroup(group);
+ }
+ const NodeKind currentKind = m_nodes.at(nearest).kind;
+ toSmooth->setChecked(currentKind == NodeKind::Smooth);
+ toSymmetric->setChecked(currentKind == NodeKind::Symmetric);
+ toCorner->setChecked(currentKind == NodeKind::Corner);
+
+ connect(toSmooth, &QAction::triggered, this, [this, nearest]() { setNodeKind(nearest, NodeKind::Smooth); });
+ connect(toSymmetric, &QAction::triggered, this, [this, nearest]() { setNodeKind(nearest, NodeKind::Symmetric); });
+ connect(toCorner, &QAction::triggered, this, [this, nearest]() { setNodeKind(nearest, NodeKind::Corner); });
+
+ if (m_nodes.size() > 2)
+ {
+ QAction *removeAct = menu.data()->addAction(tr("Supprimer le nœud le plus proche"));
+ connect(removeAct, &QAction::triggered, this, [this, nearest]() { removePathPoint(nearest); });
+ }
+ }
+
+ if (canConvertToPath)
+ {
+ QAction *convert = menu.data()->addAction(tr("Convertir en polyligne"));
+ connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathExplicitly);
+ }
+
+ QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
+ QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
+ 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();
menu.data()->addActions(d_view->contextMenuActions());
menu.data()->exec(event->screenPos());
@@ -511,148 +1124,562 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
}
/**
- @brief QetShapeItem::switchResizeMode
+ @brief QetShapeItem::toggleHandleMode
+ Cycled by clicking an already-selected shape: Size -> Corner ->
+ RotateSkew -> Size for Rectangle (the only type with a corner-radius
+ concept); Size -> RotateSkew -> Size for everything else, skipping the
+ Corner state entirely rather than showing an empty/meaningless one.
*/
-void QetShapeItem::switchResizeMode()
+/**
+ @brief QetShapeItem::nextHandleMode
+ What clicking an already-selected shape switches to from the current
+ mode -- the single source of truth for the cycle order, shared by
+ toggleHandleMode() (which acts on it) and updateModeHint() (which
+ just describes it).
+*/
+QetShapeItem::HandleMode QetShapeItem::nextHandleMode() const
{
- if (m_shapeType == Ellipse)
+ if (m_shapeType == Rectangle)
{
- if (m_resize_mode == 1)
- {
- m_resize_mode = 2;
- for (QetGraphicsHandlerItem *qghi : m_handler_vector) {
- qghi->setColor(Qt::darkGreen);
+ if (m_handleMode == HandleMode::Size) return HandleMode::Corner;
+ if (m_handleMode == HandleMode::Corner) return HandleMode::RotateSkew;
+ return HandleMode::Size;
+ }
+ if (m_shapeType == Path)
+ {
+ if (m_handleMode == HandleMode::Size) return HandleMode::NodeEdit;
+ if (m_handleMode == HandleMode::NodeEdit) return HandleMode::RotateSkew;
+ return HandleMode::Size;
+ }
+ return (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size;
+}
+
+void QetShapeItem::toggleHandleMode()
+{
+ // Changing m_handleMode changes what boundingRect() covers (it only
+ // includes nodes' guide-line extents while in NodeEdit mode) --
+ // without this, leaving NodeEdit with a far-flung handle would leave
+ // a rendering ghost behind, the same class of bug already fixed
+ // elsewhere for other state changes that affect boundingRect().
+ prepareGeometryChange();
+ m_handleMode = nextHandleMode();
+ rebuildHandles();
+ refreshInteractionHints();
+}
+
+/**
+ @brief QetShapeItem::handleModeLabel
+ Short, human name for a HandleMode -- used to build both the tooltip
+ ("click: switches to X") and, indirectly, the status bar text.
+*/
+QString QetShapeItem::handleModeLabel(HandleMode mode)
+{
+ switch (mode)
+ {
+ case HandleMode::Size: return tr("Taille");
+ case HandleMode::Corner: return tr("Coins arrondis");
+ case HandleMode::NodeEdit: return tr("Édition des nœuds");
+ case HandleMode::RotateSkew: return tr("Rotation/Inclinaison");
+ }
+ return QString();
+}
+
+/**
+ @brief QetShapeItem::updateModeHint
+ Keeps the tooltip in sync with what the next click would do -- called
+ on selection change (so it appears/disappears with the handles
+ themselves) and after every mode switch. Deliberately short: this is
+ a tooltip, not documentation -- the fuller gesture/modifier reference
+ lives in the status bar instead (see currentModeStatusHint(),
+ hoverEnterEvent()), which has room for it without popping up
+ uninvited.
+*/
+void QetShapeItem::updateModeHint()
+{
+ setToolTip(isSelected()
+ ? tr("Cliquer : mode %1").arg(handleModeLabel(nextHandleMode()))
+ : QString());
+}
+
+/**
+ @brief QetShapeItem::refreshInteractionHints
+ Keeps the tooltip text current, and -- if the shape is already being
+ hovered -- immediately re-shows both the tooltip and the status bar
+ hint rather than leaving them stuck on whatever was true before.
+ Needed because Qt only re-evaluates a tooltip, and this class only
+ re-shows the status bar, when the cursor *moves*: selecting a shape
+ (often clicked while the mouse was already sitting on it) or cycling
+ handle modes (definitely clicked while sitting on it) both change
+ what should be shown without the cursor moving at all, so without
+ this both would appear stale until the user moved away and back.
+*/
+void QetShapeItem::refreshInteractionHints()
+{
+ updateModeHint();
+
+ if (!m_hovered || !isSelected())
+ return;
+
+ const QString status = currentModeStatusHint();
+ const QString tip = toolTip();
+
+ // Deferred to the next event-loop iteration rather than shown
+ // immediately: this is called from within the same mousePressEvent
+ // that changed the mode, and Qt hides any visible tooltip as part of
+ // its own click handling -- racing an immediate re-show against that
+ // is exactly what made the tooltip appear inconsistently. Letting
+ // Qt's own click handling finish first, then re-showing, is the
+ // standard fix for this class of "act after the current event has
+ // settled" timing problem. Re-checks hover/selection on firing since
+ // they're cheap and the world could in principle have changed in the
+ // meantime, however unlikely at a zero-millisecond delay.
+ QTimer::singleShot(0, this, [this, status, tip]()
+ {
+ if (!m_hovered || !isSelected())
+ return;
+ showStatusHint(status);
+ if (!tip.isEmpty())
+ QToolTip::showText(QCursor::pos(), tip);
+ });
+}
+
+void QetShapeItem::showStatusHint(const QString &text) const
+{
+ if (text.isEmpty() || !diagram() || diagram()->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
+ editor->statusBar()->showMessage(text);
+}
+
+void QetShapeItem::clearStatusHint() const
+{
+ if (!diagram() || diagram()->views().isEmpty())
+ return;
+ if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
+ editor->statusBar()->clearMessage();
+}
+
+/**
+ @brief QetShapeItem::currentModeStatusHint
+ One-line reference for whatever handles are visible right now,
+ shown in the status bar while hovering a selected shape's body (see
+ hoverEnterEvent()) -- the modifier keys in particular (Ctrl, Shift,
+ Alt) have no other visible indication that they do anything at all.
+ Also carries the same "next mode" information as the tooltip, since
+ the status bar has room for the full picture in one place rather
+ than needing the tooltip read separately.
+*/
+QString QetShapeItem::currentModeStatusHint() const
+{
+ QString hint;
+ switch (m_handleMode)
+ {
+ case HandleMode::Size:
+ if (m_shapeType == Rectangle || m_shapeType == Ellipse)
+ {
+ hint = tr("Glisser un coin/bord : redimensionner "
+ "(Ctrl = depuis le centre, Maj = proportions, Alt = détacher en polyligne)");
+ if (m_shapeType == Ellipse)
+ hint += tr(" ; point turquoise : arc");
}
- }
- else
- {
- m_resize_mode = 1;
- for (QetGraphicsHandlerItem *qghi : m_handler_vector) {
- qghi->setColor(Qt::blue);
+ else if (m_shapeType == Line)
+ {
+ hint = tr("Glisser une extrémité : la déplacer");
}
+ else
+ {
+ hint = tr("Glisser un point : le déplacer");
+ }
+ break;
+
+ case HandleMode::Corner:
+ hint = tr("Glisser le point violet : arrondir les coins");
+ break;
+
+ case HandleMode::NodeEdit:
+ hint = tr("Glisser une poignée ou la courbe : déformer (Alt = briser la tangente) ; "
+ "Alt+glisser un point anguleux : créer des poignées ; "
+ "clic droit : menu du nœud le plus proche");
+ break;
+
+ case HandleMode::RotateSkew:
+ {
+ // Only Rectangle/Ellipse actually have SkewEdge handles in
+ // this mode (see rebuildHandles()) -- Line/Polygon/Path
+ // don't, so mentioning "un bord : inclinaison" for them
+ // would describe a handle that doesn't exist.
+ const QString handleWord = (m_shapeType == Line) ? tr("une extrémité")
+ : (m_shapeType == Rectangle || m_shapeType == Ellipse) ? tr("un coin")
+ : tr("un point");
+ hint = tr("Glisser %1 : rotation (Maj = 15°)").arg(handleWord);
+ if (m_shapeType == Rectangle || m_shapeType == Ellipse)
+ hint += tr(" ; un bord : inclinaison");
+ hint += tr(" ; point rouge : glisser pour repositionner le centre de rotation");
+ break;
}
}
- else if (m_shapeType == Rectangle)
- {
- if (m_resize_mode == 1)
- {
- m_resize_mode = 2;
- for (QetGraphicsHandlerItem *qghi : m_handler_vector)
- qghi->setColor(Qt::darkGreen);
- }
- else if (m_resize_mode == 2)
- {
- m_resize_mode = 3;
- qDeleteAll(m_handler_vector);
- m_handler_vector.clear();
- addHandler();
- for (QetGraphicsHandlerItem *qghi : m_handler_vector) {
- qghi->setColor(Qt::magenta);
- }
- }
- else if (m_resize_mode == 3)
- {
- m_resize_mode = 1;
- qDeleteAll(m_handler_vector);
- m_handler_vector.clear();
- addHandler();
- for (QetGraphicsHandlerItem *qghi : m_handler_vector) {
- qghi->setColor(Qt::blue);
- }
+ // Mentioned once here rather than repeated in every branch above:
+ // Ctrl means "free positioning, no grid snap" uniformly for every
+ // handle and for dragging the curve itself (see
+ // handlerMouseMoveEvent() and mouseMoveEvent()'s curve-drag branch),
+ // so it isn't really a property of any one mode.
+ if (!hint.isEmpty())
+ {
+ hint += tr(" (Ctrl pendant le glissement = position libre, sans accrochage à la grille)");
+ hint += tr(" — Cliquer : mode %1").arg(handleModeLabel(nextHandleMode()));
+ }
+
+ return hint;
+}
+
+/**
+ @brief QetShapeItem::handleRoleTooltip
+ Set natively on each handle item in rebuildHandles() -- Qt shows a
+ handle's own tooltip in preference to the shape's when hovering
+ directly over it, so this is what gives each handle its own distinct
+ hint instead of every one of them repeating the shape's general
+ "click: next mode" tooltip regardless of which handle you're actually
+ looking at.
+*/
+QString QetShapeItem::handleRoleTooltip(HandleRole role, int slot) const
+{
+ // Ctrl always means "free positioning, no grid snap" here -- checked
+ // once, uniformly, before any role-specific dispatch even runs (see
+ // handlerMouseMoveEvent()) -- so it belongs on every one of these,
+ // not just the handles where it also happens to do something extra
+ // (Resize's center-anchor).
+ switch (role)
+ {
+ case HandleRole::Resize:
+ {
+ // Line's endpoints use this same role but ignore mods
+ // entirely (see dragResize()'s early-return for Line) --
+ // no modifier applies to them at all.
+ if (m_shapeType == Line)
+ return tr("Glisser : déplacer ce point");
+ QString text = tr("Glisser : redimensionner (Ctrl = depuis le centre + position libre, Maj = proportions");
+ if (isResizeCornerSlot(slot))
+ text += tr(", Alt = détacher en polyligne");
+ text += ")";
+ return text;
}
+ case HandleRole::Rotate:
+ return tr("Glisser : rotation (Ctrl = position libre, Maj = 15°)");
+ case HandleRole::SkewEdge:
+ return tr("Glisser : inclinaison (Ctrl = position libre, Maj = 15°)");
+ case HandleRole::Pivot:
+ return tr("Glisser : repositionner le centre de rotation (Ctrl = position libre)");
+ case HandleRole::CornerRadius:
+ return tr("Glisser : arrondir les coins (Ctrl = position libre)");
+ case HandleRole::ArcEndpoint:
+ return tr("Glisser : ajuster l'arc (Ctrl = position libre, Maj = 15°)");
+ case HandleRole::PathAnchor:
+ {
+ QString text = tr("Glisser : déplacer le point (Ctrl = position libre");
+ if (m_shapeType == Path)
+ text += tr(", Alt = créer des poignées");
+ text += ")";
+ return text;
+ }
+ case HandleRole::PathControlIn:
+ case HandleRole::PathControlOut:
+ return tr("Glisser : déformer la courbe (Ctrl = position libre, Alt = briser la tangente)");
+ }
+ return QString();
+}
+
+
+/**
+ @brief QetShapeItem::colorForHandleRole
+ Purely cosmetic, but consistent with the existing convention of
+ color-coding what a handle currently does. Mirror-resize used to be
+ its own colored click-cycled mode (green); it is now a Ctrl modifier
+ available on every Resize handle, so only genuinely distinct
+ behaviours get their own color here.
+*/
+QColor QetShapeItem::colorForHandleRole(HandleRole role)
+{
+ switch (role)
+ {
+ case HandleRole::Resize: return Qt::blue;
+ case HandleRole::Rotate: return Qt::darkGreen;
+ case HandleRole::SkewEdge: return Qt::darkYellow;
+ case HandleRole::Pivot: return Qt::red;
+ case HandleRole::CornerRadius: return Qt::magenta;
+ case HandleRole::ArcEndpoint: return Qt::darkCyan;
+ case HandleRole::PathAnchor: return Qt::blue;
+ case HandleRole::PathControlIn:
+ case HandleRole::PathControlOut:return Qt::gray;
+ }
+ return Qt::blue;
+}
+
+QPointF QetShapeItem::cornerPoint(const QRectF &rect, int cornerIndex)
+{
+ switch (cornerIndex & 3)
+ {
+ case 0: return rect.topLeft();
+ case 1: return rect.topRight();
+ case 2: return rect.bottomRight();
+ default: return rect.bottomLeft();
}
}
-void QetShapeItem::addHandler()
+QPointF QetShapeItem::rotateHandleReference(int slot) const
{
- if (m_handler_vector.isEmpty())
+ if (m_shapeType == Line)
+ return (slot == 0) ? m_P1 : m_P2;
+ if (m_shapeType == Polygon)
+ return m_polygon.value(slot);
+ if (m_shapeType == Path)
+ return (slot < m_nodes.size()) ? m_nodes.at(slot).anchor : QPointF();
+ return cornerPoint(localRect(), slot);
+}
+
+QPointF QetShapeItem::edgeMidpoint(const QRectF &rect, int edgeIndex)
+{
+ switch (edgeIndex & 3)
{
- QVector points_vector;
- switch (m_shapeType)
- {
- case Line:
- points_vector << m_P1 << m_P2;
- break;
- case Rectangle:
- if (m_resize_mode == 3) {
- points_vector = QetGraphicsHandlerUtility::pointForRadiusRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
- }
- else {
- points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2));
- }
- break;
- case Ellipse:
- points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2));
- break;
- case Polygon:
- points_vector = m_polygon;
- break;
- }
-
- if(!points_vector.isEmpty() && scene())
- {
- m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(points_vector), QETUtils::graphicsHandlerSize(this));
-
- for(const auto handler : std::as_const(m_handler_vector))
- {
- handler->setZValue(this->zValue()+1);
- handler->setColor(Qt::blue);
- scene()->addItem(handler);
- handler->installSceneEventFilter(this);
- }
- }
+ case 0: return QPointF(rect.center().x(), rect.top());
+ case 1: return QPointF(rect.right(), rect.center().y());
+ case 2: return QPointF(rect.center().x(), rect.bottom());
+ default: return QPointF(rect.left(), rect.center().y());
}
}
/**
- @brief QetShapeItem::adjustHandlerPos
- Adjust the position of the handler item
+ @brief QetShapeItem::handlePositionFor
+ Local-coordinate position for one (role, slot) pair, given the
+ shape's *current* geometry. This is the single source of truth used
+ both to build handles from scratch (rebuildHandles()) and to move
+ existing ones during a live drag (repositionHandles()) -- the two
+ can never disagree about where a handle belongs.
*/
-void QetShapeItem::adjustHandlerPos()
+QPointF QetShapeItem::handlePositionFor(HandleRole role, int slot) const
{
- if (m_handler_vector.isEmpty()) {
- return;
- }
+ const QRectF r = localRect();
- QVector points_vector;
- switch (m_shapeType)
+ switch (role)
{
- case Line: {
- points_vector << m_P1 << m_P2;
- break;
- }
- case Rectangle: {
- if (m_resize_mode != 3) {
- points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2));
- }
- else {
- points_vector = QetGraphicsHandlerUtility::pointForRadiusRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
- }
- break;
- }
- case Ellipse: {
- points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2));
- break;
- }
- case Polygon: {
- points_vector = m_polygon;
- break;
- }
- }
+ case HandleRole::Resize:
+ if (m_shapeType == Line)
+ return (slot == 0) ? m_P1 : m_P2;
+ return QetGraphicsHandlerUtility::pointsForRect(r).value(slot);
- if (m_handler_vector.size() == points_vector.size())
- {
- points_vector = mapToScene(points_vector);
- for (int i = 0 ; i < points_vector.size() ; ++i)
- m_handler_vector.at(i)->setPos(points_vector.at(i));
+ case HandleRole::Rotate:
+ return rotateHandleReference(slot);
+
+ case HandleRole::SkewEdge:
+ return edgeMidpoint(r, slot);
+
+ case HandleRole::Pivot:
+ return m_transform.pivot;
+
+ case HandleRole::CornerRadius:
+ return QetGraphicsHandlerUtility::pointForRadiusRect(r, m_xRadius, m_yRadius).value(slot);
+
+ case HandleRole::ArcEndpoint:
+ return QetGraphicsHandlerUtility::pointsForArc(r, m_startAngle, spanAngle()).value(slot);
+
+ case HandleRole::PathAnchor:
+ if (m_shapeType == Polygon)
+ return m_polygon.value(slot);
+ return (slot < m_nodes.size()) ? m_nodes.at(slot).anchor : QPointF();
+
+ case HandleRole::PathControlIn:
+ return (slot < m_nodes.size() && m_nodes.at(slot).inHandle)
+ ? m_nodes.at(slot).anchor + *m_nodes.at(slot).inHandle : QPointF();
+ case HandleRole::PathControlOut:
+ return (slot < m_nodes.size() && m_nodes.at(slot).outHandle)
+ ? m_nodes.at(slot).anchor + *m_nodes.at(slot).outHandle : QPointF();
}
- else
+ return QPointF();
+}
+
+QVector QetShapeItem::currentHandlePositions() const
+{
+ QVector positions;
+ positions.reserve(m_handleRoles.size());
+ for (int i = 0; i < m_handleRoles.size(); ++i)
+ positions << handlePositionFor(m_handleRoles.at(i), m_handleSlot.at(i));
+ return positions;
+}
+
+/**
+ @brief QetShapeItem::scaleOnlyOffset / scaleAndShearOffset
+ A local point's offset from the pivot, run through *only* the parts of
+ the linear transform that are not currently being edited by a drag --
+ i.e. the parts that stay fixed while the user is dragging one specific
+ handle. These are the fixed reference values dragRotateHandle() and
+ dragSkewHandle() solve against, instead of round-tripping through
+ mapFromScene() (which would divide out the very parameter being
+ solved for and turn the drag into a feedback loop -- see
+ dragRotateHandle()'s comment for the concrete failure mode this
+ replaces).
+*/
+QPointF QetShapeItem::scaleOnlyOffset(const QPointF &localPoint) const
+{
+ const QPointF offset = localPoint - m_transform.pivot;
+ return QPointF(offset.x() * m_transform.scaleX, offset.y() * m_transform.scaleY);
+}
+
+QPointF QetShapeItem::scaleAndShearOffset(const QPointF &localPoint) const
+{
+ const QPointF scaled = scaleOnlyOffset(localPoint);
+ const qreal kx = qTan(qDegreesToRadians(m_transform.skewX));
+ const qreal ky = qTan(qDegreesToRadians(m_transform.skewY));
+ return QPointF(scaled.x() + kx * scaled.y(), scaled.y() + ky * scaled.x());
+}
+
+/**
+ @brief QetShapeItem::rebuildHandles
+ (Re)creates the handler items from scratch, for the current
+ shapeType()/handleMode(). Only called when the *set* of handles
+ changes -- selection, mode toggle, node count change -- never during
+ a live drag, since that would delete the very QetGraphicsHandlerItem
+ currently receiving the mouse-move events. See repositionHandles()
+ for the drag-safe alternative.
+*/
+void QetShapeItem::rebuildHandles()
+{
+ if (!m_handler_vector.isEmpty())
{
qDeleteAll(m_handler_vector);
m_handler_vector.clear();
- addHandler();
}
+ m_handleRoles.clear();
+ m_handleSlot.clear();
+
+ auto addRole = [this](HandleRole role, int slot) {
+ m_handleRoles << role;
+ m_handleSlot << slot;
+ };
+
+ switch (m_shapeType)
+ {
+ case Line:
+ if (m_handleMode == HandleMode::Size)
+ {
+ addRole(HandleRole::Resize, 0);
+ addRole(HandleRole::Resize, 1);
+ }
+ else // RotateSkew: rotate around the pivot; skewing a
+ // zero-height line isn't a meaningful operation
+ {
+ addRole(HandleRole::Rotate, 0);
+ addRole(HandleRole::Rotate, 1);
+ addRole(HandleRole::Pivot, 0);
+ }
+ break;
+
+ case Rectangle:
+ case Ellipse:
+ if (m_handleMode == HandleMode::Size)
+ {
+ for (int i = 0; i < 8; ++i) addRole(HandleRole::Resize, i);
+ }
+ else if (m_handleMode == HandleMode::Corner)
+ {
+ // Rectangle only -- toggleHandleMode() never selects this
+ // state for Ellipse, so this branch is a no-op for it.
+ for (int i = 0; i < 2; ++i) addRole(HandleRole::CornerRadius, i);
+ }
+ else // RotateSkew
+ {
+ for (int i = 0; i < 4; ++i) addRole(HandleRole::Rotate, i);
+ for (int i = 0; i < 4; ++i) addRole(HandleRole::SkewEdge, i);
+ addRole(HandleRole::Pivot, 0);
+ }
+ if (m_shapeType == Ellipse)
+ for (int i = 0; i < 2; ++i) addRole(HandleRole::ArcEndpoint, i);
+ break;
+
+ case Polygon:
+ if (m_handleMode == HandleMode::Size)
+ {
+ for (int i = 0; i < m_polygon.size(); ++i)
+ addRole(HandleRole::PathAnchor, i);
+ }
+ else // RotateSkew: any vertex can be dragged to rotate the
+ // whole polygon around the pivot; skewing doesn't have
+ // an obvious "which edge" convention for an arbitrary
+ // vertex count the way it does for a rectangle's 4
+ // fixed edges, so it's left out here too.
+ {
+ for (int i = 0; i < m_polygon.size(); ++i)
+ addRole(HandleRole::Rotate, i);
+ addRole(HandleRole::Pivot, 0);
+ }
+ break;
+
+ case Path:
+ if (m_handleMode == HandleMode::Size)
+ {
+ for (int i = 0; i < m_nodes.size(); ++i)
+ addRole(HandleRole::PathAnchor, i);
+ }
+ else if (m_handleMode == HandleMode::NodeEdit)
+ {
+ for (int i = 0; i < m_nodes.size(); ++i)
+ {
+ addRole(HandleRole::PathAnchor, i);
+ const PathNode &n = m_nodes.at(i);
+ if (n.inHandle) addRole(HandleRole::PathControlIn, i);
+ if (n.outHandle) addRole(HandleRole::PathControlOut, i);
+ }
+ }
+ else // RotateSkew
+ {
+ for (int i = 0; i < m_nodes.size(); ++i)
+ addRole(HandleRole::Rotate, i);
+ addRole(HandleRole::Pivot, 0);
+ }
+ break;
+ }
+
+ if (m_handleRoles.isEmpty() || !scene())
+ return;
+
+ const QVector positions = currentHandlePositions();
+ m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(positions), QETUtils::graphicsHandlerSize(this));
+
+ for (int i = 0; i < m_handler_vector.size(); ++i)
+ {
+ QetGraphicsHandlerItem *h = m_handler_vector.at(i);
+ h->setZValue(zValue() + 1);
+ h->setColor(colorForHandleRole(m_handleRoles.at(i)));
+ h->setToolTip(handleRoleTooltip(m_handleRoles.at(i), m_handleSlot.at(i)));
+ h->setAcceptHoverEvents(true);
+ scene()->addItem(h);
+ h->installSceneEventFilter(this);
+ }
+}
+
+/**
+ @brief QetShapeItem::repositionHandles
+ Moves the *existing* handler items to match current geometry, without
+ touching their identity or which one is mid-drag. Safe (and expected)
+ to be called on every frame of a live drag. Falls back to a full
+ rebuild only if the handle count has somehow drifted out of sync --
+ this should not normally happen, since only rebuildHandles() ever
+ changes m_handleRoles/m_handleSlot.
+*/
+void QetShapeItem::repositionHandles()
+{
+ if (m_handler_vector.isEmpty())
+ return;
+
+ const QVector positions = currentHandlePositions();
+ if (positions.size() != m_handler_vector.size())
+ {
+ rebuildHandles();
+ return;
+ }
+
+ const QVector scenePositions = mapToScene(positions);
+ for (int i = 0; i < scenePositions.size(); ++i)
+ m_handler_vector.at(i)->setPos(scenePositions.at(i));
}
void QetShapeItem::insertPoint()
@@ -705,17 +1732,754 @@ void QetShapeItem::removePoint()
}
/**
- @brief QetShapeItem::handlerMousePressEvent
- @param qghi
- @param event
+ @brief QetShapeItem::snapshotXml
+ toXml() into a private, throwaway document -- used only to hand a
+ self-contained QDomElement to PromoteShapeCommand, which deep-clones
+ it again into its own document anyway (see promoteshapecommand.cpp).
*/
-void QetShapeItem::handlerMousePressEvent()
+QDomElement QetShapeItem::snapshotXml() const
{
+ QDomDocument doc;
+ QDomElement e = toXml(doc);
+ doc.appendChild(e);
+ return e;
+}
+
+/**
+ @brief QetShapeItem::promoteRectangleOrEllipseToPolygon
+ Alt+drag on a Resize corner detaches that one vertex, at the moment
+ the shape stops being a Rectangle/Ellipse and becomes a Polygon. The
+ prior state (type, geometry, transform, style) is captured by
+ PromoteShapeCommand so a single Undo restores it exactly -- this fact
+ never touches the saved .qet file, only the session's undo stack.
+ @param detachedResizeIndex the Resize-role slot (0,2,5,7 = corners in
+ QetGraphicsHandlerUtility::pointsForRect's own ordering) being dragged
+ @param newLocalPos where that corner is being dragged to
+*/
+void QetShapeItem::promoteRectangleOrEllipseToPolygon(int detachedResizeIndex, const QPointF &newLocalPos)
+{
+ const QDomElement before = snapshotXml();
+
+ QPolygonF corners;
+ const QRectF r = localRect();
+ corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
+
+ // pointsForRect corner slots (0,2,5,7) map onto our 0..3 corner order.
+ static const QHash resizeSlotToCorner = {{0,0}, {2,1}, {5,3}, {7,2}};
+ const int cornerIndex = resizeSlotToCorner.value(detachedResizeIndex, 0);
+ corners[cornerIndex] = newLocalPos;
+
+ prepareGeometryChange();
+ m_shapeType = Polygon;
+ m_polygon = corners;
+ m_closed = true;
+
+ const QDomElement after = snapshotXml();
+
+ if (diagram())
+ {
+ auto *undo = new PromoteShapeCommand(this, before, after);
+ diagram()->undoStack().push(undo);
+ }
+
+ rebuildHandles();
+}
+
+/**
+ @brief QetShapeItem::nearestPathSegment
+ Nearest point on the whole curve to localPos, found by coarse sampling
+ each segment's cubic Bezier (24 samples is plenty for a context-menu
+ pick -- this only has to be close enough to feel right, not exact).
+ Returns {-1, 0} if there are fewer than two nodes to form a segment.
+*/
+std::pair QetShapeItem::nearestPathSegment(const QPointF &localPos) const
+{
+ const int count = m_nodes.size();
+ if (count < 2)
+ return std::make_pair(-1, 0.0);
+
+ const int segments = m_closed ? count : count - 1;
+ int bestSegment = -1;
+ qreal bestT = 0.0;
+ qreal bestDistSq = -1;
+
+ for (int i = 0; i < segments; ++i)
+ {
+ const PathNode &a = m_nodes.at(i);
+ const PathNode &b = m_nodes.at((i + 1) % count);
+ const QPointF p0 = a.anchor;
+ const QPointF p1 = a.anchor + a.outHandle.value_or(QPointF());
+ const QPointF p2 = b.anchor + b.inHandle.value_or(QPointF());
+ const QPointF p3 = b.anchor;
+
+ const int samples = 24;
+ for (int s = 0; s <= samples; ++s)
+ {
+ const qreal t = qreal(s) / samples;
+ const qreal u = 1 - t;
+ const QPointF pt = u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3;
+ const QPointF d = pt - localPos;
+ const qreal distSq = d.x() * d.x() + d.y() * d.y();
+ if (bestDistSq < 0 || distSq < bestDistSq)
+ {
+ bestDistSq = distSq;
+ bestSegment = i;
+ bestT = t;
+ }
+ }
+ }
+ return {bestSegment, bestT};
+}
+
+/**
+ @brief QetShapeItem::insertPathPoint
+ Splits the cubic Bezier between node segmentIndex and its successor at
+ parameter t, via De Casteljau's algorithm -- the two resulting halves
+ are guaranteed to retrace the original curve exactly (no kink at the
+ seam), which a naive "just add a point at this position and guess new
+ handles" approach cannot promise. When neither side of the segment
+ actually has a handle (a plain straight run between two Corner-ish
+ points), this degrades to a plain linear split with no handles at all
+ on the new node, rather than introducing phantom zero-effect handles
+ on what the user sees as a straight line.
+*/
+void QetShapeItem::insertPathPoint(int segmentIndex, qreal t)
+{
+ const QDomElement before = snapshotXml();
+
+ prepareGeometryChange();
+ const int count = m_nodes.size();
+ const int nextIndex = (segmentIndex + 1) % count;
+ PathNode &a = m_nodes[segmentIndex];
+ PathNode &b = m_nodes[nextIndex];
+
+ const QPointF p0 = a.anchor;
+ const QPointF p1 = a.anchor + a.outHandle.value_or(QPointF());
+ const QPointF p2 = b.anchor + b.inHandle.value_or(QPointF());
+ const QPointF p3 = b.anchor;
+
+ PathNode mid;
+ if (!a.outHandle && !b.inHandle)
+ {
+ mid.anchor = p0 + (p3 - p0) * t;
+ mid.kind = NodeKind::Corner;
+ }
+ else
+ {
+ const QPointF p01 = p0 + (p1 - p0) * t;
+ const QPointF p12 = p1 + (p2 - p1) * t;
+ const QPointF p23 = p2 + (p3 - p2) * t;
+ const QPointF p012 = p01 + (p12 - p01) * t;
+ const QPointF p123 = p12 + (p23 - p12) * t;
+ const QPointF p0123 = p012 + (p123 - p012) * t;
+
+ mid.anchor = p0123;
+ mid.kind = NodeKind::Smooth; // De Casteljau guarantees tangent continuity through the split point
+ mid.inHandle = p012 - p0123;
+ mid.outHandle = p123 - p0123;
+
+ a.outHandle = p01 - p0;
+ b.inHandle = p23 - p3;
+ }
+
+ // Insert right before b's current position -- except when the split
+ // segment is the closed path's wrap-around (last node back to
+ // first): inserting there at the *end* of the array places the new
+ // node correctly between old-last and old-first without shifting
+ // every other node's index.
+ m_nodes.insert(nextIndex == 0 ? m_nodes.size() : nextIndex, mid);
+
+ const QDomElement after = snapshotXml();
+ if (diagram())
+ {
+ auto *undo = new PromoteShapeCommand(this, before, after);
+ undo->setText(tr("Ajouter un point à une courbe"));
+ diagram()->undoStack().push(undo);
+ }
+
+ rebuildHandles();
+}
+
+/**
+ @brief QetShapeItem::removePathPoint
+ Deletes a node outright and lets its two former neighbours connect
+ directly using their own existing handles -- no attempt to re-fit a
+ single curve that approximates the old shape through where the point
+ used to be. That's a much harder (and inherently lossy) problem; this
+ is the same plain "just delete it" convention most editors default to.
+*/
+void QetShapeItem::removePathPoint(int nodeIndex)
+{
+ if (nodeIndex < 0 || nodeIndex >= m_nodes.size() || m_nodes.size() <= 2)
+ return;
+
+ const QDomElement before = snapshotXml();
+
+ prepareGeometryChange();
+ m_nodes.removeAt(nodeIndex);
+
+ const QDomElement after = snapshotXml();
+ if (diagram())
+ {
+ auto *undo = new PromoteShapeCommand(this, before, after);
+ undo->setText(tr("Supprimer un point d'une courbe"));
+ diagram()->undoStack().push(undo);
+ }
+
+ rebuildHandles();
+}
+
+void QetShapeItem::convertToPathExplicitly()
+{
+ if (m_shapeType != Rectangle && m_shapeType != Ellipse)
+ return;
+
+ 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;
+
+ const QDomElement after = snapshotXml();
+
+ if (diagram())
+ {
+ auto *undo = new PromoteShapeCommand(this, before, after);
+ undo->setText(tr("Convertir %1 en polyligne").arg(name()));
+ diagram()->undoStack().push(undo);
+ }
+
+ rebuildHandles();
+}
+
+/**
+ @brief QetShapeItem::mirror
+ Flips the shape around its own current pivot -- horizontal negates
+ scaleFactorX, vertical negates scaleFactorY, composing naturally with
+ whatever rotation/skew are already set rather than needing any
+ shape-specific geometry logic. Verified numerically before building
+ this: the full transform (rotation+skew+scale combined) round-trips
+ exactly under a negative scale just as it does under a positive one,
+ since a negative-scale matrix is just as invertible (non-zero
+ determinant) -- and the two interactions that seemed most likely to
+ break under mirroring turned out not to: dragRotateHandle()'s
+ angle-solve isn't hardcoded to assume positive scale (it correctly
+ flips sign for the mirrored case and still tracks the mouse exactly),
+ and dragArcEndpoint() operates entirely in local space via
+ mapFromScene(), which is exactly as exact under a mirror as without
+ one. isResizeCornerSlot() is also unaffected on inspection: it
+ identifies corners by their fixed *local* index, which mirroring
+ never changes -- only where those indices end up on screen.
+*/
+/**
+ @brief QetShapeItem::mirror
+ Flips the shape around its own current pivot.
+
+ The first version of this just negated scaleFactorX/Y directly,
+ leaving rotation and skew untouched -- correct only when both happen
+ to already be zero. Reflection doesn't commute with rotation
+ (reflect . rotate(t) = rotate(-t) . reflect) or with shear, so on
+ anything already rotated or skewed that version silently reflected
+ the shape's *original, pre-transform* geometry and then re-applied
+ the same rotation on top -- visibly changing a line's inclination
+ instead of mirroring it, exactly as reported.
+
+ Fixed by building the reflection as an actual matrix applied to the
+ CURRENT linear transform (reflecting the shape's current on-screen
+ appearance, not its original geometry), then decomposing the result
+ back into the five scalar fields via decomposeLinear() -- the same
+ function already used for importing a foreign matrix, applied here
+ to a structurally identical problem: a matrix exists, scalars that
+ reproduce it are needed. Verified against the real QTransform and
+ decomposeLinear() on a shape that was both rotated and skewed before
+ relying on it here, not just reasoned about abstractly.
+*/
+void QetShapeItem::mirror(bool horizontal)
+{
+ if (!diagram())
+ return;
+
+ const QTransform reflect = horizontal
+ ? QTransform(-1, 0, 0, 1, 0, 0)
+ : QTransform(1, 0, 0, -1, 0, 0);
+ const QTransform newLinear = m_transform.linearPart() * reflect;
+ ShapeTransform candidate = decomposeLinear(newLinear);
+ candidate.pivot = m_transform.pivot;
+
+ // decomposeLinear() becomes numerically unreliable for very extreme
+ // skew (found by stress-testing 100k random configurations: solid
+ // through +/-45 deg, some failures starting around +/-50-60 deg and
+ // beyond -- a pre-existing limitation of that shared function, not
+ // something specific to mirroring, but worth guarding against here
+ // rather than risk silently producing a visibly wrong shape for a
+ // skew angle far more extreme than normal use would ever reach.
+ // Verify the decomposition actually reproduces the intended matrix
+ // before committing to it, rather than trust it unconditionally.
+ const QTransform rebuilt = candidate.linearPart();
+ const qreal err = qMax(qMax(qAbs(rebuilt.m11() - newLinear.m11()), qAbs(rebuilt.m12() - newLinear.m12())),
+ qMax(qAbs(rebuilt.m21() - newLinear.m21()), qAbs(rebuilt.m22() - newLinear.m22())));
+ if (err > 1e-3)
+ {
+ if (!diagram()->views().isEmpty())
+ {
+ if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst()))
+ editor->statusBar()->showMessage(tr("Miroir impossible : inclinaison trop extrême pour cette forme"), 4000);
+ }
+ return;
+ }
+
+ const QDomElement before = snapshotXml();
+
+ prepareGeometryChange();
+ m_transform = candidate;
+ setTransform(m_transform.toMatrix());
+ emit transformChanged();
+
+ const QDomElement after = snapshotXml();
+
+ auto *undo = new PromoteShapeCommand(this, before, after);
+ undo->setText(horizontal ? tr("Miroir horizontal de %1").arg(name()) : tr("Miroir vertical de %1").arg(name()));
+ diagram()->undoStack().push(undo);
+}
+
+/**
+ @brief QetShapeItem::setNodeKind
+ Change a Path node's kind, via the context menu rather than a drag.
+ Promoting a Corner node to Smooth or Symmetric synthesizes whichever
+ handles it doesn't already have, from its neighbours -- direction
+ toward the far neighbour, length a third of the distance to the near
+ one, the same simple heuristic most vector editors use for a "make
+ smooth" action. Demoting to Corner leaves any existing handles
+ untouched (a Corner node can still have handles -- see
+ dragPathControlHandle() -- it just stops forcing them to stay
+ linked). Also switches into NodeEdit mode, so the result is
+ immediately visible rather than a change to data you'd otherwise
+ have to click into node-edit mode again to see.
+*/
+void QetShapeItem::setNodeKind(int nodeIndex, NodeKind kind)
+{
+ if (nodeIndex < 0 || nodeIndex >= m_nodes.size())
+ return;
+
+ const QDomElement before = snapshotXml();
+
+ prepareGeometryChange();
+ PathNode &node = m_nodes[nodeIndex];
+ node.kind = kind;
+
+ if (kind != NodeKind::Corner)
+ {
+ const int count = m_nodes.size();
+ const bool hasPrev = (nodeIndex > 0) || (m_closed && count > 1);
+ const bool hasNext = (nodeIndex < count - 1) || (m_closed && count > 1);
+ const QPointF prevAnchor = hasPrev ? m_nodes.at((nodeIndex - 1 + count) % count).anchor : node.anchor;
+ const QPointF nextAnchor = hasNext ? m_nodes.at((nodeIndex + 1) % count).anchor : node.anchor;
+
+ QPointF tangent = nextAnchor - prevAnchor;
+ const qreal tangentLength = qSqrt(tangent.x() * tangent.x() + tangent.y() * tangent.y());
+ if (tangentLength > 1e-6)
+ tangent /= tangentLength;
+
+ if (!node.outHandle && hasNext)
+ {
+ const QPointF toNext = nextAnchor - node.anchor;
+ const qreal len = qSqrt(toNext.x() * toNext.x() + toNext.y() * toNext.y()) / 3.0;
+ node.outHandle = tangent * len;
+ }
+ if (!node.inHandle && hasPrev)
+ {
+ const QPointF toPrev = prevAnchor - node.anchor;
+ const qreal len = qSqrt(toPrev.x() * toPrev.x() + toPrev.y() * toPrev.y()) / 3.0;
+ node.inHandle = -tangent * len;
+ }
+
+ if (kind == NodeKind::Symmetric && node.inHandle && node.outHandle)
+ {
+ // Equalize lengths so the two handles are true mirrors from
+ // the start, not just collinear -- otherwise a node promoted
+ // straight to Symmetric would look identical to Smooth until
+ // the next drag "fixed" it.
+ const qreal inLen = qSqrt(node.inHandle->x() * node.inHandle->x() + node.inHandle->y() * node.inHandle->y());
+ const qreal outLen = qSqrt(node.outHandle->x() * node.outHandle->x() + node.outHandle->y() * node.outHandle->y());
+ const qreal avg = (inLen + outLen) / 2.0;
+ if (inLen > 1e-6) node.inHandle = *node.inHandle * (avg / inLen);
+ if (outLen > 1e-6) node.outHandle = *node.outHandle * (avg / outLen);
+ }
+ }
+
+ const QDomElement after = snapshotXml();
+ if (diagram())
+ {
+ auto *undo = new PromoteShapeCommand(this, before, after);
+ undo->setText(tr("Modifier le type d'un nœud"));
+ diagram()->undoStack().push(undo);
+ }
+
+ m_handleMode = HandleMode::NodeEdit;
+ rebuildHandles();
+}
+
+/**
+ @brief QetShapeItem::lockAspectRatio
+ Shift-constrained resize: keep the pre-drag width:height ratio,
+ driven by whichever dimension moved proportionally more, re-anchored
+ the same way the unconstrained result already was.
+*/
+QRectF QetShapeItem::lockAspectRatio(const QRectF &oldRect, QRectF newRect, int resizeIndex, bool mirrored)
+{
+ Q_UNUSED(resizeIndex)
+ if (qFuzzyIsNull(oldRect.width()) || qFuzzyIsNull(oldRect.height()))
+ return newRect;
+
+ const qreal ratio = oldRect.width() / oldRect.height();
+ qreal w = newRect.width(), h = newRect.height();
+ const qreal wChange = qAbs(w / oldRect.width() - 1.0);
+ const qreal hChange = qAbs(h / oldRect.height() - 1.0);
+ if (wChange > hChange) h = w / ratio; else w = h * ratio;
+
+ const QPointF anchor = mirrored ? oldRect.center() : newRect.topLeft();
+ if (mirrored)
+ return QRectF(anchor.x() - w / 2.0, anchor.y() - h / 2.0, w, h);
+
+ // Re-anchor on whichever corner of newRect did not move -- simplest
+ // robust way to express "keep the fixed corner fixed" without having
+ // to re-derive which corner that is from resizeIndex.
+ const bool keepLeft = qFuzzyCompare(newRect.left(), oldRect.left()) || newRect.left() >= oldRect.right();
+ const bool keepTop = qFuzzyCompare(newRect.top(), oldRect.top()) || newRect.top() >= oldRect.bottom();
+ const qreal x = keepLeft ? newRect.left() : newRect.right() - w;
+ const qreal y = keepTop ? newRect.top() : newRect.bottom() - h;
+ return QRectF(x, y, w, h);
+}
+
+bool QetShapeItem::isResizeCornerSlot(int slot)
+{
+ // The 4 corner indices among pointsForRect's 8-point (corner+edge)
+ // ordering -- shared by dragResize() (decides whether Alt detaches
+ // this vertex) and handleRoleTooltip() (decides whether to mention
+ // that in the tooltip), so the two can't drift apart.
+ return slot == 0 || slot == 2 || slot == 5 || slot == 7;
+}
+
+void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods)
+{
+ if (m_shapeType == Line)
+ {
+ prepareGeometryChange();
+ (index == 0 ? m_P1 : m_P2) = localPos;
+ repositionHandles();
+ emit geometryChanged();
+ return;
+ }
+
+ // Alt on a corner detaches that single vertex instead of resizing --
+ // only corners (0,2,5,7 in pointsForRect's own ordering) carry that
+ // meaning; dragging an edge midpoint with Alt has no special effect.
+ if ((mods & Qt::AltModifier) && isResizeCornerSlot(index))
+ {
+ promoteRectangleOrEllipseToPolygon(index, localPos);
+ return;
+ }
+
+ const bool mirrored = mods & Qt::ControlModifier;
+ QRectF newRect = mirrored
+ ? QetGraphicsHandlerUtility::mirrorRectForPosAtIndex(localRect(), localPos, index)
+ : QetGraphicsHandlerUtility::rectForPosAtIndex(localRect(), localPos, index);
+
+ if (mods & Qt::ShiftModifier)
+ newRect = lockAspectRatio(localRect(), newRect, index, mirrored);
+
+ setRect(newRect.normalized());
+}
+
+void QetShapeItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
+{
+ // Both angles below are computed *without* ever un-rotating by the
+ // shape's current rotation: scenePivot is exactly pos()+pivot
+ // regardless of rotation/skew/scale (a linear map always fixes its
+ // own origin -- see shapetransform.h), and scaleAndShearOffset() is
+ // the corner's position with everything *except* rotation already
+ // applied. Solving "angleMouse == angleReference + rotation" this way
+ // is a plain assignment, not a recurrence -- unlike computing the
+ // angle via mapFromScene(), which divides out the *current* rotation
+ // and makes each frame's result depend on the previous frame's
+ // result: with the mouse held perfectly still that recurrence
+ // alternates between two values forever instead of settling, which is
+ // exactly the "jumps back and forth, lags behind" symptom.
+ const QPointF scenePivot = pos() + m_transform.pivot;
+ const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x()));
+
+ const QPointF reference = scaleAndShearOffset(rotateHandleReference(cornerIndex));
+ const qreal angleReference = qRadiansToDegrees(qAtan2(reference.y(), reference.x()));
+
+ qreal angle = angleMouse - angleReference;
+ if (mods & Qt::ShiftModifier)
+ angle = qRound(angle / 15.0) * 15.0;
+ setRotation(angle);
+}
+
+void QetShapeItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods)
+{
+ // Solved in closed form for the one skew component being dragged,
+ // holding rotation, scale and the *other* skew axis at their current
+ // values -- the same reasoning as dragRotateHandle() above applies
+ // here: computing this via mapFromScene() would divide out the skew
+ // value this very function is trying to determine.
+ //
+ // Q = the edge midpoint's offset from pivot, after scale only (fixed
+ // during this drag).
+ // M = the target offset (mouse position, relative to pos()+pivot),
+ // with rotation undone (fixed rotation during this drag).
+ // Shear(kx,ky) maps Q to (Qx + kx*Qy, Qy + ky*Qx) -- see
+ // shapetransform.cpp's decomposeLinear() derivation for this same
+ // convention -- so each unknown drops out with a single division.
+ const QPointF pivot = m_transform.pivot;
+ const QPointF Q = scaleOnlyOffset(edgeMidpoint(localRect(), edgeIndex));
+
+ const qreal rad = qDegreesToRadians(m_transform.rotation);
+ const qreal c = qCos(rad), s = qSin(rad);
+ const QPointF targetRel = scenePos - pos() - pivot;
+ const QPointF M(targetRel.x() * c + targetRel.y() * s,
+ -targetRel.x() * s + targetRel.y() * c);
+
+ qreal degrees;
+ if (edgeIndex == 0 || edgeIndex == 2) // N/S edge -> skewX ("sh"): M.x = Q.x + kx*Q.y
+ {
+ if (qFuzzyIsNull(Q.y())) return;
+ degrees = qRadiansToDegrees(qAtan((M.x() - Q.x()) / Q.y()));
+ if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0;
+ setSkewX(degrees);
+ }
+ else // E/W edge -> skewY ("sv"): M.y = Q.y + ky*Q.x
+ {
+ if (qFuzzyIsNull(Q.x())) return;
+ degrees = qRadiansToDegrees(qAtan((M.y() - Q.y()) / Q.x()));
+ if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0;
+ setSkewY(degrees);
+ }
+}
+
+void QetShapeItem::dragPivotHandle(const QPointF &localPos)
+{
+ // Snapping the pivot to the shape's own corners/center/edge-midpoints
+ // (Shift) is a straightforward nearest-of-9-candidates check; left as
+ // plain movement for now so the handle math above stays the focus.
+ m_pivotIsCustom = true;
+ setPivot(localPos);
+}
+
+void QetShapeItem::dragArcEndpoint(int which, const QPointF &localPos, Qt::KeyboardModifiers mods)
+{
+ const QRectF r = localRect();
+ const QPointF center = r.center();
+ const qreal rawAngle = -qRadiansToDegrees(qAtan2(localPos.y() - center.y(), localPos.x() - center.x()));
+
+ // atan2's principal value jumps by 360 degrees at the +-180 degree
+ // crossing even though the mouse only moved a hair -- unwrap it
+ // relative to this endpoint's own previous value (the smallest
+ // equivalent delta) so the stored angle, and therefore the rendered
+ // arc, changes continuously through that crossing instead of
+ // flipping to its complementary half.
+ const qreal previous = (which == 0) ? m_startAngle : m_endAngle;
+ qreal delta = std::fmod(rawAngle - previous + 540.0, 360.0) - 180.0;
+ qreal angle = previous + delta;
+
+ if (mods & Qt::ShiftModifier)
+ angle = qRound(angle / 15.0) * 15.0;
+ which == 0 ? setStartAngle(angle) : setEndAngle(angle);
+}
+
+void QetShapeItem::dragCornerRadius(int which, const QPointF &localPos)
+{
+ const qreal radius = QetGraphicsHandlerUtility::radiusForPosAtIndex(localRect(), localPos, which);
+ if (m_modifie_radius_equaly) { setXRadius(radius); setYRadius(radius); }
+ else if (which == 0) setXRadius(radius);
+ else setYRadius(radius);
+}
+
+/**
+ @brief QetShapeItem::dragPathAnchor
+ Normally just moves the anchor (Polygon vertex, or a Path node's
+ anchor -- its in/out handles are relative offsets, so they follow
+ for free). Alt+drag on a Path anchor, in NodeEdit mode, does
+ something different instead: pulls a fresh pair of symmetric handles
+ directly out of that node, letting a bare Corner node be reshaped
+ into a curve without needing the context menu's "make smooth"
+ action first -- the anchor itself stays fixed; the drag distance and
+ direction become the outgoing handle, mirrored exactly for the
+ incoming one, matching Illustrator's own "Alt+drag an anchor"
+ convention for this exact gesture. Deliberately scoped to NodeEdit
+ mode: the curve *would* still bend if allowed in Size mode too (the
+ underlying node data doesn't care what mode is active), but the new
+ handles themselves would be invisible until switching modes anyway,
+ which would just be confusing.
+*/
+void QetShapeItem::dragPathAnchor(int which, const QPointF &localPos, Qt::KeyboardModifiers mods)
+{
+ prepareGeometryChange();
+ if (m_shapeType == Polygon)
+ {
+ m_polygon.replace(which, localPos);
+ }
+ else if (which < m_nodes.size())
+ {
+ PathNode &node = m_nodes[which];
+ if ((mods & Qt::AltModifier) && m_handleMode == HandleMode::NodeEdit)
+ {
+ const QPointF offset = localPos - node.anchor;
+ node.outHandle = offset;
+ node.inHandle = -offset;
+ node.kind = NodeKind::Symmetric;
+ }
+ else
+ {
+ node.anchor = localPos; // in/out handles are relative offsets: they follow for free
+ }
+ }
+ repositionHandles();
+ emit geometryChanged();
+}
+
+/**
+ @brief QetShapeItem::dragPathControlHandle
+ Dragging a Bezier control handle. Corner nodes have no linked
+ opposite handle to update. Smooth nodes keep the two handles
+ collinear through the anchor but let each keep its own length
+ (tangent-continuous, magnitude-independent). Symmetric nodes mirror
+ both direction and length exactly. Alt always means "break a normally
+ -linked relationship" in this design -- here, detaching this handle
+ from its mirror, permanently downgrading the node to Corner, exactly
+ the same convention Alt already has on a rectangle's resize corner
+ (see dragResize()).
+*/
+void QetShapeItem::dragPathControlHandle(bool isOutHandle, int nodeIndex, const QPointF &localPos, Qt::KeyboardModifiers mods)
+{
+ if (nodeIndex >= m_nodes.size())
+ return;
+
+ prepareGeometryChange();
+ PathNode &node = m_nodes[nodeIndex];
+ const QPointF newOffset = localPos - node.anchor;
+ auto &dragged = isOutHandle ? node.outHandle : node.inHandle;
+ dragged = newOffset;
+
+ if (mods & Qt::AltModifier)
+ node.kind = NodeKind::Corner;
+ else
+ mirrorOppositeHandle(node, isOutHandle);
+
+ repositionHandles();
+}
+
+/**
+ @brief QetShapeItem::mirrorOppositeHandle
+ Given a node whose one handle (out, if justChangedIsOut; in,
+ otherwise) was just set directly, updates its *other* handle to
+ respect the node's kind -- Smooth keeps both collinear through the
+ anchor but lets each keep its own prior length (tangent-continuous,
+ magnitude-independent); Symmetric also equalizes the lengths; Corner
+ does nothing, since it has no linked handle to update. If the other
+ handle doesn't exist yet at all, it's created here rather than left
+ missing -- matching its own length to whichever handle was just
+ dragged, the only sensible default when there's no prior length of
+ its own to preserve. Shared by dragPathControlHandle() (a handle
+ dragged directly), dragCurveSegment() (both handles moved together,
+ indirectly, by dragging the curve between two nodes), and
+ dragPathAnchor()'s Alt-drag (pulling a fresh pair of handles out of
+ a bare Corner node).
+*/
+void QetShapeItem::mirrorOppositeHandle(PathNode &node, bool justChangedIsOut)
+{
+ if (node.kind == NodeKind::Corner)
+ return;
+
+ auto &changed = justChangedIsOut ? node.outHandle : node.inHandle;
+ auto &mirrored = justChangedIsOut ? node.inHandle : node.outHandle;
+ if (!changed)
+ return;
+
+ const qreal len = qSqrt(changed->x() * changed->x() + changed->y() * changed->y());
+ if (len < 1e-6)
+ return;
+
+ const QPointF direction(-changed->x() / len, -changed->y() / len);
+ const qreal keptLength = (node.kind == NodeKind::Symmetric || !mirrored)
+ ? len
+ : qSqrt(mirrored->x() * mirrored->x() + mirrored->y() * mirrored->y());
+ mirrored = direction * keptLength;
+}
+
+/**
+ @brief QetShapeItem::dragCurveSegment
+ Inkscape-style "grab the curve itself, not a handle" reshaping: moves
+ both of the segment's control points by the same amount, scaled so
+ the curve ends up passing through localPos at the parameter t where
+ the drag started. Verified algebraically and numerically before
+ shipping: for control points P1,P2 shifted by a constant delta, the
+ curve's own point at t shifts by exactly 3*(1-t)*t*delta, which is
+ exactly the factor divided back out below -- so the new curve passes
+ through localPos exactly, not approximately.
+ Always measured against the *original* control points captured when
+ the drag started (m_curveDragOriginalP1/P2), not the current
+ (possibly already-adjusted, this same drag) ones -- otherwise each
+ frame's adjustment would compound on top of the last, sending the
+ curve shooting off far past the cursor instead of tracking it.
+ Near either endpoint (t within 5% of 0 or 1) the curve is barely
+ sensitive to its control points at all -- the same reason grabbing a
+ suspension bridge's deck right next to a pylon barely moves it -- so
+ those clicks are left alone rather than requiring huge, unpredictable
+ handle movements for a small visual change; they're also close enough
+ to an anchor that the user most likely meant to grab that instead.
+*/
+void QetShapeItem::dragCurveSegment(int segmentIndex, qreal t, const QPointF &localPos)
+{
+ if (t < 0.05 || t > 0.95)
+ return;
+
+ const int count = m_nodes.size();
+ const int nextIndex = (segmentIndex + 1) % count;
+ PathNode &a = m_nodes[segmentIndex];
+ PathNode &b = m_nodes[nextIndex];
+
+ const QPointF p0 = a.anchor;
+ const QPointF p3 = b.anchor;
+ const qreal u = 1 - t;
+
+ const QPointF originalCurvePoint =
+ u*u*u*p0 + 3*u*u*t*m_curveDragOriginalP1 + 3*u*t*t*m_curveDragOriginalP2 + t*t*t*p3;
+ const QPointF desired = localPos - originalCurvePoint;
+ const qreal factor = 3 * u * t; // > 0 given the t range guarded above
+ const QPointF handleDelta = desired / factor;
+
+ prepareGeometryChange();
+ a.outHandle = (m_curveDragOriginalP1 - p0) + handleDelta;
+ b.inHandle = (m_curveDragOriginalP2 - p3) + handleDelta;
+
+ mirrorOppositeHandle(a, true);
+ mirrorOppositeHandle(b, false);
+
+ repositionHandles();
+}
+
+/**
+ @brief QetShapeItem::handlerMousePressEvent
+ @param handlerIndex
+*/
+void QetShapeItem::handlerMousePressEvent(int handlerIndex)
+{
+ Q_UNUSED(handlerIndex)
m_old_P1 = m_P1;
m_old_P2 = m_P2;
m_old_polygon = m_polygon;
m_old_xRadius = m_xRadius;
m_old_yRadius = m_yRadius;
+ m_old_transform = m_transform;
+ m_old_pos = pos();
+ m_old_nodes = m_nodes;
if(m_xRadius == 0 && m_yRadius == 0) {
m_modifie_radius_equaly = true;
}
@@ -723,117 +2487,152 @@ void QetShapeItem::handlerMousePressEvent()
/**
@brief QetShapeItem::handlerMouseMoveEvent
+ @param handlerIndex
@param event
*/
-void QetShapeItem::handlerMouseMoveEvent(QGraphicsSceneMouseEvent *event)
+void QetShapeItem::handlerMouseMoveEvent(int handlerIndex, QGraphicsSceneMouseEvent *event)
{
- QPointF new_pos = event->scenePos();
- if (event->modifiers() != Qt::ControlModifier)
- new_pos = Diagram::snapToGrid(event->scenePos());
- new_pos = mapFromScene(new_pos);
+ QPointF scenePos = event->scenePos();
+ // Bitwise flag check, not exact equality -- see
+ // DiagramEventAddShape::mousePressEvent's identical fix and comment:
+ // modifiers() == Ctrl alone fails the moment any other key (Alt, for
+ // dragPathAnchor()'s handle-creation gesture) is also held,
+ // silently falling through to snapToGrid() even though Ctrl is held.
+ if (!(event->modifiers() & Qt::ControlModifier))
+ scenePos = Diagram::snapToGrid(scenePos);
- switch (m_shapeType)
+ const HandleRole role = m_handleRoles.value(handlerIndex, HandleRole::Resize);
+ const int slot = m_handleSlot.value(handlerIndex, 0);
+ const Qt::KeyboardModifiers mods = event->modifiers();
+
+ // Rotate and SkewEdge are deliberately handled in scene space -- see
+ // their comments for why mapFromScene() (used for every other role
+ // below) is exactly the wrong tool for them.
+ if (role == HandleRole::Rotate) { dragRotateHandle(slot, scenePos, mods); return; }
+ if (role == HandleRole::SkewEdge) { dragSkewHandle(slot, scenePos, mods); return; }
+
+ const QPointF new_pos = mapFromScene(scenePos);
+
+ switch (role)
{
- case Line:
- prepareGeometryChange();
- m_vector_index == 0 ? m_P1 = new_pos : m_P2 = new_pos;
- adjustHandlerPos();
- break;
-
- case Rectangle:
- if (m_resize_mode == 1) {
- setRect(QetGraphicsHandlerUtility::rectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index));
- break;
- }
- else if (m_resize_mode == 2) {
- setRect(QetGraphicsHandlerUtility::mirrorRectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index));
- break;
- }
- else {
- qreal radius = QetGraphicsHandlerUtility::radiusForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index);
- if(m_modifie_radius_equaly) {
- setXRadius(radius);
- setYRadius(radius);
- }
- else if(m_vector_index == 0) {
- setXRadius(radius);
- }
- else {
- setYRadius(radius);
- }
- adjustHandlerPos();
- break;
- }
- case Ellipse:
- if (m_resize_mode == 1) {
- setRect(QetGraphicsHandlerUtility::rectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index));
- break;
- }
- else {
- setRect(QetGraphicsHandlerUtility::mirrorRectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index));
- break;
- }
-
- case Polygon:
- prepareGeometryChange();
- m_polygon.replace(m_vector_index, new_pos);
- adjustHandlerPos();
- break;
- } //End switch
+ case HandleRole::Resize: dragResize(slot, new_pos, mods); break;
+ case HandleRole::Pivot: dragPivotHandle(new_pos); break;
+ case HandleRole::CornerRadius: dragCornerRadius(slot, new_pos); break;
+ case HandleRole::ArcEndpoint: dragArcEndpoint(slot, new_pos, mods); break;
+ case HandleRole::PathAnchor: dragPathAnchor(slot, new_pos, mods); break;
+ case HandleRole::PathControlIn: dragPathControlHandle(false, slot, new_pos, mods); break;
+ case HandleRole::PathControlOut: dragPathControlHandle(true, slot, new_pos, mods); break;
+ case HandleRole::Rotate:
+ case HandleRole::SkewEdge:
+ break; // handled above
+ }
}
/**
@brief QetShapeItem::handlerMouseReleaseEvent
- @param qghi
- @param event
+ @param handlerIndex
*/
-void QetShapeItem::handlerMouseReleaseEvent()
+void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
{
m_modifie_radius_equaly = false;
+ const HandleRole role = m_handleRoles.value(handlerIndex, HandleRole::Resize);
+ const int slot = m_handleSlot.value(handlerIndex, 0);
- if (diagram())
+ if (!diagram())
+ return;
+
+ QUndoCommand *undo = nullptr;
+
+ switch (role)
{
- QPropertyUndoCommand *undo = nullptr;
- if ((m_shapeType & (Line | Rectangle | Ellipse)) && ((m_P1 != m_old_P1 || m_P2 != m_old_P2) ||
- (m_old_xRadius != XRadius() || m_old_yRadius != m_yRadius))
- )
- {
- switch(m_shapeType)
+ case HandleRole::Resize:
+ if (m_shapeType == Line)
{
- case Line: {
- undo = new QPropertyUndoCommand(this, "line",QLineF(m_old_P1, m_old_P2), QLineF(m_P1, m_P2));
- break;
- }
- case Rectangle: {
- if (m_resize_mode == 1 || m_resize_mode == 2) {
- undo = new QPropertyUndoCommand(this, "rect",QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized());
- }
- else if (m_resize_mode == 3)
- {
- undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius);
- QPropertyUndoCommand *undo_ = new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo);
- undo_->setAnimated();
- }
- break;
- }
- case Ellipse: {
- undo = new QPropertyUndoCommand(this, "rect",QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized());
- break;
- }
- case Polygon: break;
+ if (m_P1 != m_old_P1 || m_P2 != m_old_P2)
+ undo = new QPropertyUndoCommand(this, "line", QLineF(m_old_P1, m_old_P2), QLineF(m_P1, m_P2));
}
- if (undo) {
- undo->setAnimated(true, false);
+ else if (m_P1 != m_old_P1 || m_P2 != m_old_P2)
+ {
+ undo = new QPropertyUndoCommand(this, "rect", QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized());
}
- }
- else if (m_shapeType == Polygon && (m_polygon != m_old_polygon))
- undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon);
+ break;
- if(undo)
- {
+ case HandleRole::Rotate:
+ if (!qFuzzyCompare(m_transform.rotation, m_old_transform.rotation))
+ undo = new QPropertyUndoCommand(this, "rotation", m_old_transform.rotation, m_transform.rotation);
+ break;
+
+ case HandleRole::SkewEdge:
+ if (!qFuzzyCompare(m_transform.skewX, m_old_transform.skewX))
+ 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);
+ break;
+
+ case HandleRole::Pivot:
+ if (m_transform.pivot != m_old_transform.pivot)
+ {
+ undo = new QUndoCommand(tr("Deplacer le centre de rotation"));
+ new QPropertyUndoCommand(this, "pos", m_old_pos, pos(), undo);
+ new QPropertyUndoCommand(this, "pivot", m_old_transform.pivot, m_transform.pivot, undo);
+ }
+ break;
+
+ case HandleRole::CornerRadius:
+ if (m_old_xRadius != m_xRadius || m_old_yRadius != m_yRadius)
+ {
+ undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius);
+ new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo);
+ }
+ 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.
+ break;
+
+ case HandleRole::PathAnchor:
+ if (m_shapeType == Polygon && m_polygon != m_old_polygon)
+ {
+ undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon);
+ }
+ else if (m_shapeType == Path && m_nodes != m_old_nodes)
+ {
+ // PathNode/QVector isn't a Q_PROPERTY-friendly
+ // type, so this reuses PromoteShapeCommand's generic
+ // before/after XML snapshot mechanism instead of a
+ // dedicated undo class -- swap in the old nodes just
+ // long enough to snapshot them, then restore.
+ const QVector after = m_nodes;
+ m_nodes = m_old_nodes;
+ const QDomElement before = snapshotXml();
+ m_nodes = after;
+ const QDomElement afterXml = snapshotXml();
+ undo = new PromoteShapeCommand(this, before, afterXml);
+ }
+ break;
+
+ case HandleRole::PathControlIn:
+ case HandleRole::PathControlOut:
+ if (m_nodes != m_old_nodes)
+ {
+ const QVector after = m_nodes;
+ m_nodes = m_old_nodes;
+ const QDomElement before = snapshotXml();
+ m_nodes = after;
+ const QDomElement afterXml = snapshotXml();
+ undo = new PromoteShapeCommand(this, before, afterXml);
+ }
+ break;
+ }
+
+ if (undo)
+ {
+ if (undo->text().isEmpty())
undo->setText(tr("Modifier %1").arg(name()));
- diagram()->undoStack().push(undo);
- }
+ diagram()->undoStack().push(undo);
}
}
@@ -847,6 +2646,14 @@ bool QetShapeItem::fromXml(const QDomElement &e)
{
if (e.tagName() != "shape") return (false);
+ // fromXml() is also used to *restore* an already-displayed item's
+ // state (PromoteShapeCommand's undo/redo), not just to populate a
+ // freshly constructed one -- without this, Qt has no way to know the
+ // item's *previous* on-screen bounding rect needs repainting once the
+ // geometry underneath it changes, so the old rendering stays stuck
+ // until something unrelated forces a repaint of that area.
+ prepareGeometryChange();
+
is_movable_ = (e.attribute("is_movable").toInt());
m_closed = e.attribute("closed", "0").toInt();
m_pen = QETXML::penFromXml(e.firstChildElement("pen"));
@@ -856,7 +2663,7 @@ bool QetShapeItem::fromXml(const QDomElement &e)
QMetaEnum me = metaObject()->enumerator(metaObject()->indexOfEnumerator("ShapeType"));
m_shapeType = QetShapeItem::ShapeType(me.keysToValue(type.toStdString().data()));
- if (m_shapeType != Polygon)
+ if (m_shapeType != Polygon && m_shapeType != Path)
{
m_P1.setX(e.attribute("x1", nullptr).toDouble());
m_P1.setY(e.attribute("y1", nullptr).toDouble());
@@ -869,13 +2676,89 @@ bool QetShapeItem::fromXml(const QDomElement &e)
setYRadius(e.attribute("ry", "0").toDouble());
}
}
- else {
+ if (m_shapeType == Polygon)
+ {
+ // fromXml() must be safe to call on an already-populated item, not
+ // just a freshly constructed one: PromoteShapeCommand's undo/redo
+ // (and the automatic redo() that QUndoStack::push() performs the
+ // instant a command is pushed) both call it to *restore* a prior
+ // state, on an object that already has geometry in it. Appending
+ // onto whatever is already there -- rather than replacing it --
+ // silently duplicated every point on the very first undo-worthy
+ // edit.
+ m_polygon.clear();
for(const QDomElement& de : QET::findInDomElement(e, "points", "point")) {
m_polygon << QPointF(de.attribute("x", nullptr).toDouble(), de.attribute("y", nullptr).toDouble());
}
}
+ else if (m_shapeType == Path)
+ {
+ m_nodes.clear();
+ for (const QDomElement &nodeElement : QET::findInDomElement(e, "nodes", "node"))
+ {
+ PathNode node;
+ node.anchor = QPointF(nodeElement.attribute("x").toDouble(), nodeElement.attribute("y").toDouble());
+ const QString kind = nodeElement.attribute("kind", "corner");
+ node.kind = (kind == "smooth") ? NodeKind::Smooth
+ : (kind == "symmetric") ? NodeKind::Symmetric
+ : NodeKind::Corner;
+ QDomElement in = nodeElement.firstChildElement("in");
+ if (!in.isNull())
+ node.inHandle = QPointF(in.attribute("dx").toDouble(), in.attribute("dy").toDouble());
+ QDomElement out = nodeElement.firstChildElement("out");
+ if (!out.isNull())
+ node.outHandle = QPointF(out.attribute("dx").toDouble(), out.attribute("dy").toDouble());
+ m_nodes << node;
+ }
+ }
+
+ QDomElement transformElement = e.firstChildElement("transform");
+ if (!transformElement.isNull())
+ {
+ m_transform.rotation = transformElement.attribute("rotation", "0").toDouble();
+ m_transform.skewX = transformElement.attribute("skewX", "0").toDouble();
+ m_transform.skewY = transformElement.attribute("skewY", "0").toDouble();
+ m_transform.scaleX = transformElement.attribute("scaleX", "1").toDouble();
+ m_transform.scaleY = transformElement.attribute("scaleY", "1").toDouble();
+ m_transform.pivot = QPointF(transformElement.attribute("pivotX", "0").toDouble(),
+ transformElement.attribute("pivotY", "0").toDouble());
+ m_pivotIsCustom = true;
+ }
+ else
+ {
+ m_transform = ShapeTransform();
+ m_transform.pivot = localRect().center();
+ m_pivotIsCustom = false;
+ }
+ setTransform(m_transform.toMatrix());
+
+ QDomElement arcElement = e.firstChildElement("arc");
+ if (!arcElement.isNull() && m_shapeType == Ellipse)
+ {
+ m_startAngle = arcElement.attribute("startAngle", "0").toDouble();
+ m_endAngle = m_startAngle + arcElement.attribute("spanAngle", "360").toDouble();
+ const QString closure = arcElement.attribute("closure", "none");
+ m_arcClosure = (closure == "chord") ? Chord : (closure == "pie") ? Pie : NoClosure;
+ }
+
+ if (e.hasAttribute("posX") || e.hasAttribute("posY"))
+ {
+ QGraphicsItem::setPos(e.attribute("posX", "0").toDouble(),
+ e.attribute("posY", "0").toDouble());
+ }
+
setZValue(e.attribute("z", QString::number(this->zValue())).toDouble());
+ // fromXml() can change anything about the shape -- geometry, node
+ // count, even shapeType() itself (undoing a Rectangle->Polygon
+ // promotion) -- so a full rebuild, not just a reposition, is the only
+ // choice that's guaranteed consistent with whatever state was just
+ // restored. Only when selected: an unselected item should have no
+ // handles at all, and this can run on any item in the diagram, not
+ // just the one currently being interacted with.
+ if (isSelected())
+ rebuildHandles();
+
return (true);
}
@@ -897,12 +2780,12 @@ QDomElement QetShapeItem::toXml(QDomDocument &document) const
result.setAttribute("is_movable", bool(is_movable_));
result.setAttribute("closed", bool(m_closed));
- if (m_shapeType != Polygon)
+ if (m_shapeType != Polygon && m_shapeType != Path)
{
- result.setAttribute("x1", QString::number(mapToScene(m_P1).x()));
- result.setAttribute("y1", QString::number(mapToScene(m_P1).y()));
- result.setAttribute("x2", QString::number(mapToScene(m_P2).x()));
- result.setAttribute("y2", QString::number(mapToScene(m_P2).y()));
+ result.setAttribute("x1", QString::number(m_P1.x()));
+ result.setAttribute("y1", QString::number(m_P1.y()));
+ result.setAttribute("x2", QString::number(m_P2.x()));
+ result.setAttribute("y2", QString::number(m_P2.y()));
if (m_shapeType == Rectangle)
{
@@ -910,19 +2793,86 @@ QDomElement QetShapeItem::toXml(QDomDocument &document) const
result.setAttribute("ry", QString::number(m_yRadius));
}
}
- else
+ if (m_shapeType == Polygon)
{
QDomElement points = document.createElement("points");
for (QPointF p : m_polygon)
{
QDomElement point = document.createElement("point");
- QPointF pf = mapToScene(p);
- point.setAttribute("x", QString::number(pf.x()));
- point.setAttribute("y", QString::number(pf.y()));
+ point.setAttribute("x", QString::number(p.x()));
+ point.setAttribute("y", QString::number(p.y()));
points.appendChild(point);
}
result.appendChild(points);
}
+ else if (m_shapeType == Path)
+ {
+ QDomElement nodes = document.createElement("nodes");
+ for (const PathNode &n : m_nodes)
+ {
+ QDomElement node = document.createElement("node");
+ node.setAttribute("x", QString::number(n.anchor.x()));
+ node.setAttribute("y", QString::number(n.anchor.y()));
+ if (n.kind != NodeKind::Corner)
+ node.setAttribute("kind", n.kind == NodeKind::Smooth ? "smooth" : "symmetric");
+ if (n.inHandle)
+ {
+ QDomElement in = document.createElement("in");
+ in.setAttribute("dx", QString::number(n.inHandle->x()));
+ in.setAttribute("dy", QString::number(n.inHandle->y()));
+ node.appendChild(in);
+ }
+ if (n.outHandle)
+ {
+ QDomElement out = document.createElement("out");
+ out.setAttribute("dx", QString::number(n.outHandle->x()));
+ out.setAttribute("dy", QString::number(n.outHandle->y()));
+ node.appendChild(out);
+ }
+ nodes.appendChild(node);
+ }
+ result.appendChild(nodes);
+ }
+
+ // Omitted entirely at identity, exactly like an absent radius today --
+ // this is what keeps old files, and files that never touch rotation,
+ // byte-for-byte unchanged.
+ if (!m_transform.isIdentity() || m_pivotIsCustom)
+ {
+ QDomElement transformElement = document.createElement("transform");
+ transformElement.setAttribute("rotation", QString::number(m_transform.rotation));
+ transformElement.setAttribute("skewX", QString::number(m_transform.skewX));
+ transformElement.setAttribute("skewY", QString::number(m_transform.skewY));
+ transformElement.setAttribute("scaleX", QString::number(m_transform.scaleX));
+ transformElement.setAttribute("scaleY", QString::number(m_transform.scaleY));
+ transformElement.setAttribute("pivotX", QString::number(m_transform.pivot.x()));
+ transformElement.setAttribute("pivotY", QString::number(m_transform.pivot.y()));
+ result.appendChild(transformElement);
+ }
+
+ if (m_shapeType == Ellipse && (!isFullEllipse() || m_arcClosure != NoClosure))
+ {
+ QDomElement arcElement = document.createElement("arc");
+ arcElement.setAttribute("startAngle", QString::number(m_startAngle));
+ arcElement.setAttribute("spanAngle", QString::number(spanAngle()));
+ if (m_arcClosure != NoClosure)
+ arcElement.setAttribute("closure", m_arcClosure == Chord ? "chord" : "pie");
+ result.appendChild(arcElement);
+ }
+
+ // pos() is meaningful now that pivot moves (and, transparently, every
+ // ordinary resize via the auto-recentring pivot -- see setRect())
+ // compensate through it rather than through m_P1/m_P2. It was never
+ // written here before; omitted at every reload it silently reset to
+ // (0,0), which is exactly the "position shifts slightly on reload"
+ // bug -- geometry and transform round-tripped correctly, but the
+ // placement component of the two together did not.
+ if (!pos().isNull())
+ {
+ result.setAttribute("posX", QString::number(pos().x()));
+ result.setAttribute("posY", QString::number(pos().y()));
+ }
+
result.setAttribute("z", QString::number(this->zValue()));
return(result);
@@ -937,6 +2887,21 @@ QDomElement QetShapeItem::toXml(QDomDocument &document) const
*/
bool QetShapeItem::toDXF(const QString &filepath,const QPen &pen)
{
+ // A non-identity transform means the shape is no longer axis-aligned
+ // in scene space: mapping just two opposite corners and building a
+ // new axis-aligned QRectF from them (the old Rectangle/Ellipse path
+ // below) would silently produce the wrong quadrilateral. Fall back to
+ // exporting the mapped outline as a polygon/polyline instead, exactly
+ // the path already used for Polygon today.
+ if (!m_transform.isIdentity() && (m_shapeType == Rectangle || m_shapeType == Ellipse))
+ {
+ const QPolygonF mappedOutline = mapToScene(outline().toFillPolygon());
+ if (m_shapeType == Rectangle || isFullEllipse())
+ Createdxf::drawPolygon(filepath, mappedOutline, Createdxf::dxfColor(pen));
+ else
+ Createdxf::drawPolyline(filepath, mappedOutline, Createdxf::dxfColor(pen));
+ return true;
+ }
switch (m_shapeType)
{
@@ -959,11 +2924,34 @@ bool QetShapeItem::toDXF(const QString &filepath,const QPen &pen)
Createdxf::dxfColor(pen));
return true;
case Polygon:
- if(m_polygon.isClosed())
+ // m_closed, not m_polygon.isClosed(): m_polygon's own points
+ // never include a duplicate closing point in this design --
+ // outline() relies on m_closed + QPainterPath::closeSubpath()
+ // for the visual effect, so isClosed()'s geometric check
+ // (do the first and last points happen to coincide?) almost
+ // always reads false regardless of the user's actual intent.
+ if (m_closed)
Createdxf::drawPolygon(filepath,m_polygon,Createdxf::dxfColor(pen));
else
Createdxf::drawPolyline(filepath,m_polygon,Createdxf::dxfColor(pen));
return true;
+ case Path:
+ {
+ // toSubpathPolygons(), not outline().toFillPolygon(): the
+ // latter exists for fill-rendering, which inherently needs a
+ // closed shape, so it silently appends a closing point onto
+ // *any* path regardless of m_closed -- confirmed directly
+ // against the real QPainterPath before relying on it here.
+ // toSubpathPolygons() has no such fill-oriented bias and
+ // correctly preserves the open/closed distinction.
+ const QList subpaths = outline().toSubpathPolygons();
+ const QPolygonF flattened = subpaths.isEmpty() ? QPolygonF() : mapToScene(subpaths.first());
+ if (m_closed)
+ Createdxf::drawPolygon(filepath, flattened, Createdxf::dxfColor(pen));
+ else
+ Createdxf::drawPolyline(filepath, flattened, Createdxf::dxfColor(pen));
+ return true;
+ }
default:
return false;
}
@@ -990,8 +2978,9 @@ QString QetShapeItem::name() const
switch (m_shapeType) {
case Line: return tr("une ligne");
case Rectangle: return tr("un rectangle");
- case Ellipse: return tr("une éllipse");
+ case Ellipse: return isFullEllipse() ? tr("une éllipse") : tr("un arc");
case Polygon: return tr("une polyligne");
+ case Path: return tr("une courbe");
default: return tr("une shape");
}
}
diff --git a/sources/qetgraphicsitem/qetshapeitem.h b/sources/qetgraphicsitem/qetshapeitem.h
index d13f71475..fdb7fe6a4 100644
--- a/sources/qetgraphicsitem/qetshapeitem.h
+++ b/sources/qetgraphicsitem/qetshapeitem.h
@@ -20,8 +20,11 @@
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
#include "qetgraphicsitem.h"
+#include "shapetransform.h"
#include
+#include
+#include
class QDomElement;
class QDomDocument;
@@ -30,8 +33,10 @@ class QAction;
/**
@brief The QetShapeItem class
- this class is used to draw a basic shape (line, rectangle, ellipse)
- into a diagram, that can be saved to .qet file.
+ this class is used to draw a basic shape (line, rectangle, ellipse,
+ polygon or free-form path) into a diagram, that can be saved to a
+ .qet file. Beyond its local geometry, a shape may also carry a
+ rotation/skew/scale (see ShapeTransform) around an arbitrary pivot.
*/
class QetShapeItem : public QetGraphicsItem
{
@@ -46,21 +51,86 @@ class QetShapeItem : public QetGraphicsItem
Q_PROPERTY(qreal xRadius READ XRadius WRITE setXRadius NOTIFY XRadiusChanged)
Q_PROPERTY(qreal yRadius READ YRadius WRITE setYRadius NOTIFY YRadiusChanged)
+ // One property per ShapeTransform scalar -- this is what lets a plain
+ // QPropertyUndoCommand(this, "rotation", oldValue, newValue) work for
+ // every handle, exactly like xRadius/yRadius already do for corner
+ // rounding.
+ Q_PROPERTY(qreal rotation READ rotation WRITE setRotation NOTIFY transformChanged)
+ Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged)
+ Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged)
+ Q_PROPERTY(qreal scaleFactorX READ scaleFactorX WRITE setScaleFactorX NOTIFY transformChanged)
+ Q_PROPERTY(qreal scaleFactorY READ scaleFactorY WRITE setScaleFactorY NOTIFY transformChanged)
+ Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged)
+
+ Q_PROPERTY(qreal startAngle READ startAngle WRITE setStartAngle NOTIFY arcChanged)
+ Q_PROPERTY(qreal endAngle READ endAngle WRITE setEndAngle NOTIFY arcChanged)
+
signals:
void penChanged();
void brushChanged();
void closeChanged();
void XRadiusChanged();
void YRadiusChanged();
-
-
+ void transformChanged();
+ void arcChanged();
+ void geometryChanged(); // P1/P2, polygon points, or path nodes changed -- lets the properties panel stay in sync while a handle is dragged, not just when it's typed into
+
public:
enum ShapeType {Line =1,
Rectangle =2,
Ellipse =4,
- Polygon =8 };
+ Polygon =8,
+ Path =16 };
Q_ENUM (ShapeType)
+ enum ArcClosure {NoClosure = 0, Chord = 1, Pie = 2};
+ Q_ENUM (ArcClosure)
+
+ // Point of a Path shape. Anchors are in the same local coordinate
+ // frame as an ordinary Polygon's points; handle offsets are stored
+ // *relative to the anchor*, so moving a node never has to rewrite
+ // its own handle coordinates.
+ enum class NodeKind {Corner, Smooth, Symmetric};
+ struct PathNode {
+ QPointF anchor;
+ NodeKind kind = NodeKind::Corner;
+ std::optional inHandle;
+ std::optional outHandle;
+
+ bool operator==(const PathNode &other) const {
+ return anchor == other.anchor && kind == other.kind
+ && inHandle == other.inHandle && outHandle == other.outHandle;
+ }
+ };
+
+ // Orthogonal to ShapeType: which handle set is currently shown.
+ // Cycled by clicking an already-selected shape without dragging:
+ // Size -> Corner -> RotateSkew -> Size for Rectangle (the only
+ // type with a corner-radius concept); Size -> NodeEdit ->
+ // RotateSkew -> Size for Path (reveals control handles for
+ // every node that has any); Size -> RotateSkew -> Size for
+ // everything else. One unified click-cycle for every shape
+ // type, rather than a separate, less discoverable gesture
+ // (e.g. double-click) for any one shape's extra mode.
+ enum class HandleMode {Size, Corner, NodeEdit, RotateSkew};
+
+ // index conventions, deliberately matched to what already exists
+ // rather than invented fresh:
+ // Resize 0..7, same order as QetGraphicsHandlerUtility::pointsForRect
+ // (this is exactly today's Rectangle/Ellipse handle set --
+ // only the Ctrl/Shift dispatch around it is new)
+ // Rotate 0..3, corners: NW, NE, SE, SW
+ // SkewEdge 0..3, edges: N, E, S, W
+ // CornerRadius 0..1, same order as QetGraphicsHandlerUtility::pointForRadiusRect
+ // ArcEndpoint 0 = start angle, 1 = end angle
+ enum class HandleRole {
+ Resize, // Size mode
+ Rotate, SkewEdge, Pivot, // RotateSkew mode
+ CornerRadius, // Rectangle, always shown alongside Size handles
+ ArcEndpoint, // Ellipse, always shown
+ PathAnchor, PathControlIn, PathControlOut // Polygon/Path, node-edit mode (see setPathNodes())
+ };
+
enum { Type = UserType + 1008 };
QetShapeItem(
@@ -102,6 +172,41 @@ class QetShapeItem : public QetGraphicsItem
qreal YRadius() const {return m_yRadius;}
void setYRadius(qreal Y);
+ //Transform: one accessor pair per ShapeTransform scalar (see
+ //the Q_PROPERTY block above for why they are not grouped into
+ //a single property).
+ const ShapeTransform &shapeTransform() const {return m_transform;}
+ qreal rotation() const {return m_transform.rotation;}
+ void setRotation(qreal degrees);
+ qreal skewX() const {return m_transform.skewX;}
+ void setSkewX(qreal degrees);
+ qreal skewY() const {return m_transform.skewY;}
+ void setSkewY(qreal degrees);
+ qreal scaleFactorX() const {return m_transform.scaleX;}
+ void setScaleFactorX(qreal factor);
+ qreal scaleFactorY() const {return m_transform.scaleY;}
+ void setScaleFactorY(qreal factor);
+ QPointF pivot() const {return m_transform.pivot;}
+ void setPivot(const QPointF &pivot); // moves the pivot handle: compensates pos() so the shape does not jump
+ void resetPivotToBoundingRectCenter();
+ void enableNodeEditMode(); // Path only: switches to NodeEdit mode, so every node's control handles become visible
+
+ //Arc: only meaningful when shapeType() == Ellipse. A full
+ //ellipse is just an arc with span 360 -- there is no separate
+ //Arc shape type.
+ qreal startAngle() const {return m_startAngle;}
+ void setStartAngle(qreal degrees);
+ qreal endAngle() const {return m_endAngle;}
+ void setEndAngle(qreal degrees);
+ qreal spanAngle() const {return m_endAngle - m_startAngle;}
+ bool isFullEllipse() const {return qFuzzyCompare(qAbs(spanAngle()), qreal(360));}
+ ArcClosure arcClosure() const {return m_arcClosure;}
+ void setArcClosure(ArcClosure closure);
+
+ //Path (Bezier): only meaningful when shapeType() == Path.
+ const QVector &pathNodes() const {return m_nodes;}
+ void setPathNodes(const QVector &nodes);
+
//Methods available for polygon shape
int pointsCount () const;
void setNextPoint (QPointF P);
@@ -118,6 +223,8 @@ class QetShapeItem : public QetGraphicsItem
void hoverEnterEvent (QGraphicsSceneHoverEvent *event) override;
void hoverLeaveEvent (QGraphicsSceneHoverEvent *event) override;
void mousePressEvent (QGraphicsSceneMouseEvent *event) override;
+ void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override;
+ void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(
GraphicsItemChange change,
const QVariant &value) override;
@@ -128,17 +235,63 @@ class QetShapeItem : public QetGraphicsItem
QGraphicsSceneContextMenuEvent *event) override;
private:
- void switchResizeMode();
- void addHandler();
- void adjustHandlerPos();
+ void toggleHandleMode();
+ HandleMode nextHandleMode() const; // what a click would switch to from here -- shared by toggleHandleMode() and the tooltip
+ void updateModeHint(); // keeps the tooltip in sync with nextHandleMode()
+ void refreshInteractionHints(); // updateModeHint(), plus an immediate re-show of tooltip/status bar if currently hovered
+ void showStatusHint(const QString &text) const;
+ void clearStatusHint() const;
+ QString currentModeStatusHint() const; // richer, one-line gesture/modifier reference for the status bar, shown when hovering the shape body
+ QString handleRoleTooltip(HandleRole role, int slot) const; // shown natively by Qt when hovering that specific handle, and pushed to the status bar too
+ static QString handleModeLabel(HandleMode mode);
+ static bool isResizeCornerSlot(int slot); // true for the 4 corner slots (of 8) in QetGraphicsHandlerUtility::pointsForRect's ordering
+ void rebuildHandles(); // (re)creates handler items -- only when the *set* of handles changes
+ void repositionHandles(); // moves existing handler items -- safe to call every frame of a live drag
void insertPoint();
void removePoint();
-
- void handlerMousePressEvent();
- void handlerMouseMoveEvent(QGraphicsSceneMouseEvent *event);
- void handlerMouseReleaseEvent();
+ void convertToPathExplicitly(); // context-menu action; see promoteRectangleOrEllipseToPolygon()
+ 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
- ///ATTRIBUTES
+ void handlerMousePressEvent(int handlerIndex);
+ void handlerMouseMoveEvent(int handlerIndex, QGraphicsSceneMouseEvent *event);
+ void handlerMouseReleaseEvent(int handlerIndex);
+
+ // One dispatch function per handle role -- called from
+ // handlerMouseMoveEvent() with the mouse position already
+ // mapped to local coordinates and any grid snap applied.
+ // dragResize() covers both the plain and Ctrl/Shift/Alt-modified
+ // interpretations of the whole Size handle set.
+ void dragResize (int index, const QPointF &localPos, Qt::KeyboardModifiers mods);
+ void dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods);
+ void dragSkewHandle (int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods);
+ void dragPivotHandle (const QPointF &localPos);
+ void dragArcEndpoint (int which, const QPointF &localPos, Qt::KeyboardModifiers mods);
+ void dragCornerRadius(int which, const QPointF &localPos);
+ void dragPathAnchor (int which, const QPointF &localPos, Qt::KeyboardModifiers mods);
+ void dragPathControlHandle(bool isOutHandle, int nodeIndex, const QPointF &localPos, Qt::KeyboardModifiers mods);
+ void mirrorOppositeHandle(PathNode &node, bool justChangedIsOut); // shared by dragPathControlHandle() and dragCurveSegment()
+ void dragCurveSegment(int segmentIndex, qreal t, const QPointF &localPos); // Inkscape-style "grab the curve itself"
+
+ void promoteRectangleOrEllipseToPolygon(int detachedResizeIndex, const QPointF &newLocalPos);
+ std::pair nearestPathSegment(const QPointF &localPos) const; // {segmentIndex, t}, -1 if fewer than 2 nodes
+ void insertPathPoint(int segmentIndex, qreal t); // De Casteljau split -- see .cpp for why
+ void removePathPoint(int nodeIndex);
+ QDomElement snapshotXml() const; // helper for PromoteShapeCommand: toXml() into a throwaway document
+
+ QRectF localRect() const;
+ QPainterPath outline() const; // raw, unstroked path for the current type; shared by shape() and paint()
+ QVector currentHandlePositions() const; // in m_handleRoles/m_handleSlot order, local coordinates
+ QPointF handlePositionFor(HandleRole role, int slot) const;
+ QPointF rotateHandleReference(int slot) const; // the point a Rotate handle tracks, before rotation is applied
+ QPointF scaleOnlyOffset(const QPointF &localPoint) const; // (localPoint - pivot), scaled, in the pre-shear frame
+ QPointF scaleAndShearOffset(const QPointF &localPoint) const; // same, with current shear also applied -- the pre-rotation frame
+ static QColor colorForHandleRole(HandleRole role);
+ static QRectF lockAspectRatio(const QRectF &oldRect, QRectF newRect, int resizeIndex, bool mirrored);
+ static QPointF cornerPoint(const QRectF &rect, int cornerIndex); // 0=NW,1=NE,2=SE,3=SW
+ static QPointF edgeMidpoint(const QRectF &rect, int edgeIndex); // 0=N, 1=E, 2=S, 3=W
+
+ ///ATTRIBUTES
private:
ShapeType m_shapeType;
QPen m_pen;
@@ -153,7 +306,6 @@ class QetShapeItem : public QetGraphicsItem
int m_vector_index;
bool m_closed = false,
m_modifie_radius_equaly = false;
- int m_resize_mode = 1;
QVector m_handler_vector;
QAction *m_insert_point,
*m_remove_point;
@@ -161,5 +313,26 @@ class QetShapeItem : public QetGraphicsItem
m_yRadius = 0,
m_old_xRadius,
m_old_yRadius;
+
+ ShapeTransform m_transform;
+ ShapeTransform m_old_transform;
+ HandleMode m_handleMode = HandleMode::Size;
+ QVector m_handleRoles; // parallel to m_handler_vector, one role per handle
+ QVector m_handleSlot; // parallel to m_handler_vector, meaning depends on role (see HandleRole)
+ QPointF m_old_pos;
+
+ qreal m_startAngle = 0;
+ qreal m_endAngle = 360;
+ ArcClosure m_arcClosure = NoClosure;
+ bool m_pivotIsCustom = false; // false: pivot auto-follows the bounding-rect center on every geometry edit
+ bool m_deferHandleReposition = false; // true while setPivot() is applying its two related updates together
+
+ QVector m_nodes;
+ QVector m_old_nodes; // saved at handle press, for undo
+ int m_curveDragSegment = -1; // >=0 while dragging the curve itself (not a handle) between two nodes
+ qreal m_curveDragT = 0; // parameter along that segment where the drag started
+ QPointF m_curveDragOriginalP1, m_curveDragOriginalP2; // absolute control points at drag start, fixed for the whole drag
+ QPointF m_curveDragPressPos; // local position at press, to tell a plain click from a real drag
+ bool m_curveDragEngaged = false; // false until the drag actually exceeds a small threshold
};
#endif // QETSHAPEITEM_H
diff --git a/sources/qetgraphicsitem/shapetransform.cpp b/sources/qetgraphicsitem/shapetransform.cpp
new file mode 100644
index 000000000..b2eb98077
--- /dev/null
+++ b/sources/qetgraphicsitem/shapetransform.cpp
@@ -0,0 +1,88 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "shapetransform.h"
+
+#include
+
+bool ShapeTransform::isIdentity() const
+{
+ return qFuzzyIsNull(rotation) && qFuzzyIsNull(skewX) && qFuzzyIsNull(skewY)
+ && qFuzzyCompare(scaleX, qreal(1)) && qFuzzyCompare(scaleY, qreal(1));
+}
+
+QTransform ShapeTransform::linearPart() const
+{
+ QTransform t;
+ t.rotate(rotation);
+ t.shear(qTan(qDegreesToRadians(skewX)), qTan(qDegreesToRadians(skewY)));
+ t.scale(scaleX, scaleY);
+ return t;
+}
+
+QTransform ShapeTransform::toMatrix() const
+{
+ QTransform t;
+ t.translate(pivot.x(), pivot.y());
+ t.rotate(rotation);
+ t.shear(qTan(qDegreesToRadians(skewX)), qTan(qDegreesToRadians(skewY)));
+ t.scale(scaleX, scaleY);
+ t.translate(-pivot.x(), -pivot.y());
+ return t;
+}
+
+bool ShapeTransform::operator==(const ShapeTransform &other) const
+{
+ return qFuzzyCompare(rotation, other.rotation)
+ && qFuzzyCompare(skewX, other.skewX)
+ && qFuzzyCompare(skewY, other.skewY)
+ && qFuzzyCompare(scaleX, other.scaleX)
+ && qFuzzyCompare(scaleY, other.scaleY)
+ && pivot == other.pivot;
+}
+
+ShapeTransform decomposeLinear(const QTransform &m)
+{
+ const qreal scaleX = qSqrt(m.m11() * m.m11() + m.m12() * m.m12());
+ const qreal rotation = qRadiansToDegrees(qAtan2(m.m12(), m.m11()));
+ const qreal rad = qDegreesToRadians(rotation);
+ const qreal c = qCos(rad), s = qSin(rad);
+
+ // v * R(-rotation): undo the rotation, leaving the pure shear+scale
+ // contribution the local y-axis picked up from Shear(sh,0)*Scale(sx,sy).
+ const qreal vx = m.m21() * c + m.m22() * s;
+ const qreal scaleY = -m.m21() * s + m.m22() * c;
+ const qreal skewX = qFuzzyIsNull(scaleY) ? 0.0 : qRadiansToDegrees(qAtan(vx / scaleY));
+
+ ShapeTransform result;
+ result.rotation = rotation;
+ result.skewX = skewX;
+ result.skewY = 0;
+ result.scaleX = scaleX;
+ result.scaleY = scaleY;
+ return result;
+}
+
+QPointF compensatedPositionForNewPivot(
+ const QPointF &position,
+ const QPointF &oldPivot,
+ const QPointF &newPivot,
+ const QTransform &linearPart)
+{
+ return position + (oldPivot - linearPart.map(oldPivot))
+ - (newPivot - linearPart.map(newPivot));
+}
diff --git a/sources/qetgraphicsitem/shapetransform.h b/sources/qetgraphicsitem/shapetransform.h
new file mode 100644
index 000000000..b24fc3c74
--- /dev/null
+++ b/sources/qetgraphicsitem/shapetransform.h
@@ -0,0 +1,90 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef SHAPETRANSFORM_H
+#define SHAPETRANSFORM_H
+
+#include
+#include
+
+/**
+ @brief The ShapeTransform struct
+ Rotation, skew and scale applied to a shape's local geometry around an
+ arbitrary local pivot point.
+
+ These five scalars plus a pivot are a convenient, directly-editable
+ basis for 2D affine transforms: every handle in the UI changes exactly
+ one field, which is what makes per-handle drag math simple. Note that
+ a plain 2x2 linear map only has 4 true degrees of freedom, one fewer
+ than (rotation, skewX, skewY, scaleX, scaleY) -- so this is not a
+ *unique* representation of a matrix, only a convenient one. That
+ redundancy is harmless for editing (nothing here ever needs to invert
+ the forward direction) and only matters to decomposeLinear() below,
+ which exists for later consumers (flattening a group transform onto
+ its children, importing a foreign matrix) rather than everyday use.
+
+ The resulting matrix is meant to be handed directly to
+ QGraphicsItem::setTransform(). QGraphicsItem::transformOriginPoint()
+ is deliberately NOT used anywhere in this design: it only centers
+ QGraphicsItem's own rotation()/scale() convenience properties, and has
+ no effect on a custom transform() matrix -- the pivot has to be baked
+ into the matrix itself, as toMatrix() does. QGraphicsItem::pos()
+ supplies the translation on top, unchanged from how shapes are
+ already positioned and moved today.
+*/
+struct ShapeTransform
+{
+ qreal rotation = 0; // degrees
+ qreal skewX = 0; // degrees
+ qreal skewY = 0; // degrees
+ qreal scaleX = 1;
+ qreal scaleY = 1;
+ QPointF pivot; // local coordinates; caller decides the default (usually local bbox center)
+
+ bool isIdentity() const;
+
+ // Rotate+shear+scale about the origin, ignoring pivot -- this is the
+ // "linear part" used by compensatedPositionForNewPivot() and by
+ // anything that needs to map a direction/offset rather than a point.
+ QTransform linearPart() const;
+
+ // The pivot-centered matrix to pass to QGraphicsItem::setTransform().
+ QTransform toMatrix() const;
+
+ bool operator==(const ShapeTransform &other) const;
+ bool operator!=(const ShapeTransform &other) const { return !(*this == other); }
+};
+
+// Canonical decomposition of an arbitrary 2x2 linear map into the five
+// scalars above, always returning skewY == 0 (all shear folded into
+// skewX -- matching how most authoring tools represent shear on export).
+// Rebuilding via ShapeTransform::linearPart() from the result reproduces
+// the input matrix exactly; the individual scalar values are not
+// guaranteed to match whatever scalars (if any) originally produced that
+// matrix, only the matrix itself is guaranteed to match.
+ShapeTransform decomposeLinear(const QTransform &linear);
+
+// Position adjustment needed when the pivot moves, so the shape does not
+// visibly jump on screen: call once, at the end of a pivot-handle drag,
+// alongside setting the new pivot.
+QPointF compensatedPositionForNewPivot(
+ const QPointF &position,
+ const QPointF &oldPivot,
+ const QPointF &newPivot,
+ const QTransform &linearPart);
+
+#endif // SHAPETRANSFORM_H
diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp
index 6d53c46c6..9cde7a0b2 100644
--- a/sources/qeticons.cpp
+++ b/sources/qeticons.cpp
@@ -128,6 +128,7 @@ namespace QET {
QIcon ObjectUnlocked;
QIcon Orientations;
QIcon PartArc;
+ QIcon PartBezier;
QIcon PartCircle;
QIcon PartEllipse;
QIcon PartLine;
@@ -570,6 +571,7 @@ void QET::Icons::initIcons()
ObjectUnlocked .addFile(":/ico/22x22/object-unlocked.png");
Orientations .addFile(":/ico/16x16/orientations.png");
PartArc .addFile(":/ico/22x22/arc.png");
+ PartBezier .addFile(":/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg");
PartCircle .addFile(":/ico/16x16/circle.png");
PartEllipse .addFile(":/ico/22x22/ellipse.png");
PartLine .addFile(":/ico/22x22/line.png");
diff --git a/sources/qeticons.h b/sources/qeticons.h
index a81c79e17..5c0e981da 100644
--- a/sources/qeticons.h
+++ b/sources/qeticons.h
@@ -136,6 +136,7 @@ namespace QET {
extern QIcon ObjectUnlocked;
extern QIcon Orientations;
extern QIcon PartArc;
+ extern QIcon PartBezier;
extern QIcon PartCircle;
extern QIcon PartEllipse;
extern QIcon PartLine;
diff --git a/sources/ui/imagecropdialog.cpp b/sources/ui/imagecropdialog.cpp
new file mode 100644
index 000000000..9f2e3a8dc
--- /dev/null
+++ b/sources/ui/imagecropdialog.cpp
@@ -0,0 +1,287 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "imagecropdialog.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+ // Fits the dialog comfortably on a normal screen regardless of the
+ // source image's own resolution -- same reasoning, and same value,
+ // as ImageTransparentColorDialog's identical constant.
+ constexpr int MAX_DISPLAY_SIZE = 350;
+}
+
+/**
+ @brief CropAreaWidget::CropAreaWidget
+ @param sourceImage the full-resolution image to crop
+ @param initialCropRect a previously-chosen crop rect (in
+ sourceImage's own coordinates), or an empty QRect to start covering
+ the whole image
+ @param parent
+*/
+CropAreaWidget::CropAreaWidget(const QImage &sourceImage, const QRect &initialCropRect, QWidget *parent) :
+ QWidget(parent),
+ m_source(sourceImage)
+{
+ const qreal scaleW = qreal(MAX_DISPLAY_SIZE) / m_source.width();
+ const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height();
+ m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one
+
+ const QSize displaySize = m_source.size() * m_displayScale;
+ m_displayImageRect = QRect(QPoint(0, 0), displaySize);
+ setFixedSize(displaySize);
+
+ if (initialCropRect.isEmpty())
+ {
+ // No previous crop to start from -- covers the whole image, so
+ // the user shrinks it from there rather than having to first
+ // drag out a rectangle from nothing.
+ m_cropRect = m_displayImageRect;
+ }
+ else
+ {
+ // Converting an existing, original-space crop rect back into
+ // display-space -- the reverse of what cropRect() does going
+ // the other way.
+ m_cropRect = QRect(
+ qRound(initialCropRect.left() * m_displayScale),
+ qRound(initialCropRect.top() * m_displayScale),
+ qRound(initialCropRect.width() * m_displayScale),
+ qRound(initialCropRect.height() * m_displayScale))
+ .intersected(m_displayImageRect);
+ }
+}
+
+/**
+ @brief CropAreaWidget::resetToFullImage
+*/
+void CropAreaWidget::resetToFullImage()
+{
+ m_cropRect = m_displayImageRect;
+ update();
+ emit cropRectChanged();
+}
+
+/**
+ @brief CropAreaWidget::cropRect
+ @return the crop rectangle in the original, full-resolution image's
+ own coordinates -- m_cropRect itself is display-space only.
+*/
+QRect CropAreaWidget::cropRect() const
+{
+ QRect r(
+ qRound(m_cropRect.left() / m_displayScale),
+ qRound(m_cropRect.top() / m_displayScale),
+ qRound(m_cropRect.width() / m_displayScale),
+ qRound(m_cropRect.height() / m_displayScale));
+ return r.intersected(m_source.rect());
+}
+
+/**
+ @brief CropAreaWidget::handleAt
+ @param pos widget-space position to test
+ @return whichever handle (or the rect's own interior, for moving)
+ pos is close enough to, or DragMode::None if it's not near anything
+*/
+CropAreaWidget::DragMode CropAreaWidget::handleAt(const QPoint &pos) const
+{
+ const int tol = HANDLE_SIZE; // a generous hit area, larger than the drawn handle itself, since a hit-test exactly matching a small handle's visible size is needlessly hard to land
+ auto near = [&](const QPoint &p) { return (pos - p).manhattanLength() <= tol; };
+
+ if (near(m_cropRect.topLeft())) return DragMode::ResizeNW;
+ if (near(m_cropRect.topRight())) return DragMode::ResizeNE;
+ if (near(m_cropRect.bottomLeft())) return DragMode::ResizeSW;
+ if (near(m_cropRect.bottomRight())) return DragMode::ResizeSE;
+ if (near(QPoint(m_cropRect.center().x(), m_cropRect.top()))) return DragMode::ResizeN;
+ if (near(QPoint(m_cropRect.center().x(), m_cropRect.bottom()))) return DragMode::ResizeS;
+ if (near(QPoint(m_cropRect.left(), m_cropRect.center().y()))) return DragMode::ResizeW;
+ if (near(QPoint(m_cropRect.right(), m_cropRect.center().y()))) return DragMode::ResizeE;
+
+ if (m_cropRect.contains(pos))
+ return DragMode::Move;
+
+ return DragMode::None;
+}
+
+/**
+ @brief CropAreaWidget::applyDrag
+ Recomputes m_cropRect from m_dragStartRect plus however far pos has
+ moved from m_dragStartPos, according to m_dragMode -- always
+ clamped to stay within the displayed image and never smaller than
+ MIN_CROP_SIZE, so the rect can never escape its bounds or collapse
+ to nothing while being dragged.
+ @param pos current widget-space mouse position
+*/
+void CropAreaWidget::applyDrag(const QPoint &pos)
+{
+ const QPoint delta = pos - m_dragStartPos;
+ QRect r = m_dragStartRect;
+
+ auto clampLeft = [&](int v) { r.setLeft(qBound(m_displayImageRect.left(), v, r.right() - MIN_CROP_SIZE)); };
+ auto clampRight = [&](int v) { r.setRight(qBound(r.left() + MIN_CROP_SIZE, v, m_displayImageRect.right())); };
+ auto clampTop = [&](int v) { r.setTop(qBound(m_displayImageRect.top(), v, r.bottom() - MIN_CROP_SIZE)); };
+ auto clampBottom = [&](int v) { r.setBottom(qBound(r.top() + MIN_CROP_SIZE, v, m_displayImageRect.bottom())); };
+
+ switch (m_dragMode)
+ {
+ case DragMode::Move:
+ {
+ // Clamping the translation itself, rather than each edge of
+ // the resulting rect independently -- clamping edges one at
+ // a time would shrink or distort the rect the instant it
+ // hit a boundary, instead of just stopping its movement.
+ QRect moved = m_dragStartRect.translated(delta);
+ if (moved.left() < m_displayImageRect.left())
+ moved.translate(m_displayImageRect.left() - moved.left(), 0);
+ if (moved.right() > m_displayImageRect.right())
+ moved.translate(m_displayImageRect.right() - moved.right(), 0);
+ if (moved.top() < m_displayImageRect.top())
+ moved.translate(0, m_displayImageRect.top() - moved.top());
+ if (moved.bottom() > m_displayImageRect.bottom())
+ moved.translate(0, m_displayImageRect.bottom() - moved.bottom());
+ r = moved;
+ break;
+ }
+ case DragMode::ResizeN: clampTop(m_dragStartRect.top() + delta.y()); break;
+ case DragMode::ResizeS: clampBottom(m_dragStartRect.bottom() + delta.y()); break;
+ case DragMode::ResizeE: clampRight(m_dragStartRect.right() + delta.x()); break;
+ case DragMode::ResizeW: clampLeft(m_dragStartRect.left() + delta.x()); break;
+ case DragMode::ResizeNE: clampTop(m_dragStartRect.top() + delta.y()); clampRight(m_dragStartRect.right() + delta.x()); break;
+ case DragMode::ResizeNW: clampTop(m_dragStartRect.top() + delta.y()); clampLeft(m_dragStartRect.left() + delta.x()); break;
+ case DragMode::ResizeSE: clampBottom(m_dragStartRect.bottom() + delta.y()); clampRight(m_dragStartRect.right() + delta.x()); break;
+ case DragMode::ResizeSW: clampBottom(m_dragStartRect.bottom() + delta.y()); clampLeft(m_dragStartRect.left() + delta.x()); break;
+ case DragMode::None: return;
+ }
+
+ m_cropRect = r;
+}
+
+void CropAreaWidget::mousePressEvent(QMouseEvent *event)
+{
+ if (event->button() != Qt::LeftButton)
+ return;
+ m_dragMode = handleAt(event->pos());
+ m_dragStartPos = event->pos();
+ m_dragStartRect = m_cropRect;
+}
+
+void CropAreaWidget::mouseMoveEvent(QMouseEvent *event)
+{
+ if (m_dragMode == DragMode::None)
+ return;
+ applyDrag(event->pos());
+ update();
+ emit cropRectChanged();
+}
+
+void CropAreaWidget::mouseReleaseEvent(QMouseEvent *event)
+{
+ Q_UNUSED(event);
+ m_dragMode = DragMode::None;
+}
+
+/**
+ @brief CropAreaWidget::paintEvent
+ Draws the image, dims everything outside the crop rect (four
+ strips around it, rather than one overlay with a hole cut out of
+ it, since that would need a more involved clip/composition setup
+ for no real benefit here), then the rect's own dashed border and
+ its eight handles.
+*/
+void CropAreaWidget::paintEvent(QPaintEvent *)
+{
+ QPainter painter(this);
+ painter.drawImage(m_displayImageRect, m_source);
+
+ const QColor dim(0, 0, 0, 140);
+ painter.fillRect(QRect(m_displayImageRect.left(), m_displayImageRect.top(),
+ m_displayImageRect.width(), m_cropRect.top() - m_displayImageRect.top()), dim);
+ painter.fillRect(QRect(m_displayImageRect.left(), m_cropRect.bottom() + 1,
+ m_displayImageRect.width(), m_displayImageRect.bottom() - m_cropRect.bottom()), dim);
+ painter.fillRect(QRect(m_displayImageRect.left(), m_cropRect.top(),
+ m_cropRect.left() - m_displayImageRect.left(), m_cropRect.height()), dim);
+ painter.fillRect(QRect(m_cropRect.right() + 1, m_cropRect.top(),
+ m_displayImageRect.right() - m_cropRect.right(), m_cropRect.height()), dim);
+
+ QPen borderPen(Qt::white);
+ borderPen.setStyle(Qt::DashLine);
+ painter.setPen(borderPen);
+ painter.setBrush(Qt::NoBrush);
+ painter.drawRect(m_cropRect);
+
+ painter.setPen(Qt::black);
+ painter.setBrush(Qt::white);
+ auto drawHandle = [&](const QPoint ¢er) {
+ painter.drawRect(QRect(center.x() - HANDLE_SIZE / 2, center.y() - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE));
+ };
+ drawHandle(m_cropRect.topLeft());
+ drawHandle(m_cropRect.topRight());
+ drawHandle(m_cropRect.bottomLeft());
+ drawHandle(m_cropRect.bottomRight());
+ drawHandle(QPoint(m_cropRect.center().x(), m_cropRect.top()));
+ drawHandle(QPoint(m_cropRect.center().x(), m_cropRect.bottom()));
+ drawHandle(QPoint(m_cropRect.left(), m_cropRect.center().y()));
+ drawHandle(QPoint(m_cropRect.right(), m_cropRect.center().y()));
+}
+
+/**
+ @brief ImageCropDialog::ImageCropDialog
+ @param pixmap the image to crop
+ @param existingCropRect a crop rectangle remembered from a previous
+ session, or an empty QRect if there isn't one yet
+ @param parent
+*/
+ImageCropDialog::ImageCropDialog(const QPixmap &pixmap, const QRect &existingCropRect, QWidget *parent) :
+ QDialog(parent)
+{
+ setWindowTitle(tr("Rogner l'image"));
+
+ m_cropArea = new CropAreaWidget(pixmap.toImage(), existingCropRect, this);
+
+ auto *hint = new QLabel(tr("Faites glisser les poignées, ou l'intérieur du cadre, pour ajuster la zone à conserver."), this);
+ hint->setWordWrap(true);
+
+ auto *resetButton = new QPushButton(tr("Réinitialiser"), this);
+ resetButton->setToolTip(tr("Revenir à l'image complète, sans rognage"));
+ connect(resetButton, &QPushButton::clicked, m_cropArea, &CropAreaWidget::resetToFullImage);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+ buttons->addButton(resetButton, QDialogButtonBox::ResetRole);
+
+ auto *layout = new QVBoxLayout(this);
+ layout->addWidget(hint);
+ layout->addWidget(m_cropArea, 0, Qt::AlignHCenter);
+ layout->addWidget(buttons);
+
+ connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+/**
+ @brief ImageCropDialog::cropRect
+ @return the chosen crop rectangle, in the original pixmap's own coordinates
+*/
+QRect ImageCropDialog::cropRect() const
+{
+ return m_cropArea->cropRect();
+}
diff --git a/sources/ui/imagecropdialog.h b/sources/ui/imagecropdialog.h
new file mode 100644
index 000000000..28256d3a5
--- /dev/null
+++ b/sources/ui/imagecropdialog.h
@@ -0,0 +1,121 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef IMAGE_CROP_DIALOG_H
+#define IMAGE_CROP_DIALOG_H
+
+#include
+#include
+#include
+#include
+
+class QMouseEvent;
+class QPaintEvent;
+class QPushButton;
+
+/**
+ @brief The CropAreaWidget class
+ Displays the image scaled to fit a reasonable dialog size, with a
+ draggable, resizable crop rectangle overlaid on top -- the area
+ outside it dimmed, the usual visual convention for "this part goes
+ away" during a crop, so it's never ambiguous which region survives.
+ All internal geometry (the crop rect, drag handling, hit-testing)
+ works in display-space pixel coordinates; only cropRect() converts
+ back to the original, full-resolution image's own coordinates for
+ the caller, the same original-vs-display distinction already used
+ for colour sampling in ImageTransparentColorDialog.
+*/
+class CropAreaWidget : public QWidget
+{
+ Q_OBJECT
+
+ public:
+ /// @param sourceImage the full, uncropped image to crop from
+ /// @param initialCropRect a previously-chosen crop rectangle, in
+ /// sourceImage's own coordinates, to start from -- an empty
+ /// (default-constructed) QRect means "no previous crop", i.e.
+ /// start covering the whole image.
+ explicit CropAreaWidget(const QImage &sourceImage, const QRect &initialCropRect = QRect(), QWidget *parent = nullptr);
+
+ /// The current crop rectangle, in the original image's own
+ /// coordinates (not display-space) -- always clamped to the
+ /// image's own bounds and never degenerate (zero width/height).
+ QRect cropRect() const;
+
+ public slots:
+ /// Resets the crop rectangle back to covering the entire image --
+ /// the direct answer to "I want to undo the crop, not just
+ /// adjust it", since dragging every handle back out by hand to
+ /// exactly the original extent is needlessly fiddly.
+ void resetToFullImage();
+
+ signals:
+ void cropRectChanged();
+
+ protected:
+ void paintEvent(QPaintEvent *event) override;
+ void mousePressEvent(QMouseEvent *event) override;
+ void mouseMoveEvent(QMouseEvent *event) override;
+ void mouseReleaseEvent(QMouseEvent *event) override;
+
+ private:
+ enum class DragMode { None, Move, ResizeN, ResizeS, ResizeE, ResizeW, ResizeNE, ResizeNW, ResizeSE, ResizeSW };
+
+ DragMode handleAt(const QPoint &pos) const;
+ void applyDrag(const QPoint &pos);
+
+ QImage m_source;
+ qreal m_displayScale = 1.0;
+ QRect m_displayImageRect; // where the (possibly scaled) pixmap actually sits within this widget
+ QRect m_cropRect; // in display-space, relative to m_displayImageRect's own origin
+
+ DragMode m_dragMode = DragMode::None;
+ QPoint m_dragStartPos;
+ QRect m_dragStartRect;
+
+ static constexpr int HANDLE_SIZE = 8;
+ static constexpr int MIN_CROP_SIZE = 16;
+};
+
+/**
+ @brief The ImageCropDialog class
+ Self-contained modal dialog wrapping CropAreaWidget with OK/Cancel --
+ the same reasoning as ImageTransparentColorDialog: this needs
+ neither undo-during-drag nor coexistence with other tools, only a
+ rectangle the user drags out against a pixmap already in hand.
+*/
+class ImageCropDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ /// @param pixmap the full, uncropped image to crop from
+ /// @param existingCropRect a crop rectangle remembered from a
+ /// previous session, shown from the start rather than resetting
+ /// to the full image every time the dialog is reopened -- an
+ /// empty QRect means there isn't one yet.
+ explicit ImageCropDialog(const QPixmap &pixmap, const QRect &existingCropRect = QRect(), QWidget *parent = nullptr);
+
+ /// The chosen crop rectangle, in the original pixmap's own
+ /// coordinates. Only meaningful after the dialog was accepted.
+ QRect cropRect() const;
+
+ private:
+ CropAreaWidget *m_cropArea;
+};
+
+#endif // IMAGE_CROP_DIALOG_H
diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp
index c6437fa14..b2cccca2b 100644
--- a/sources/ui/imagepropertieswidget.cpp
+++ b/sources/ui/imagepropertieswidget.cpp
@@ -20,6 +20,7 @@
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
#include "../diagram.h"
#include "../qetgraphicsitem/diagramimageitem.h"
+#include "../qeticons.h"
#include "../ui_imagepropertieswidget.h"
/**
@@ -34,6 +35,11 @@ ImagePropertiesWidget::ImagePropertiesWidget(DiagramImageItem *image, QWidget *p
m_image(nullptr)
{
ui->setupUi(this);
+ // Matches the .ui file's own initial checked="true" -- toggled()
+ // only fires on a subsequent CHANGE, not for the state a widget
+ // already starts in, so the icon needs setting explicitly here or
+ // the button would show blank until first clicked.
+ ui->m_lock_ratio_tb->setIcon(QET::Icons::ObjectLocked);
this->setDisabled(true);
setImageItem(image);
}
@@ -58,12 +64,16 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image)
this->setEnabled(true);
if (m_image == image) return;
if (m_image)
- disconnect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
+ disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
m_image = image;
- connect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
+ connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
m_movable = image->isMovable();
- m_scale = m_image->scale();
+ m_scaleX = m_image->scaleFactorX();
+ m_scaleY = m_image->scaleFactorY();
+ m_rotation = m_image->rotationAngle();
+ m_skewX = m_image->skewX();
+ m_skewY = m_image->skewY();
updateUi();
}
@@ -77,16 +87,20 @@ void ImagePropertiesWidget::apply()
if (m_image->diagram())
{
- if (m_live_edit) disconnect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
+ if (m_live_edit) disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
QUndoCommand *undo = associatedUndo();
if (undo)
m_image->diagram()->undoStack().push(undo);
- if (m_live_edit) connect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi);
+ if (m_live_edit) connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi);
}
- m_scale = m_image->scale();
+ m_scaleX = m_image->scaleFactorX();
+ m_scaleY = m_image->scaleFactorY();
+ m_rotation = m_image->rotationAngle();
+ m_skewX = m_image->skewX();
+ m_skewY = m_image->skewY();
}
/**
@@ -97,7 +111,11 @@ void ImagePropertiesWidget::reset()
{
if(!m_image) return;
- m_image->setScale(m_scale);
+ m_image->setScaleFactorX(m_scaleX);
+ m_image->setScaleFactorY(m_scaleY);
+ m_image->setRotationAngle(m_rotation);
+ m_image->setSkewX(m_skewX);
+ m_image->setSkewY(m_skewY);
m_image->setMovable(m_movable);
updateUi();
}
@@ -115,13 +133,19 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit)
if (m_live_edit)
{
- connect (ui->m_scale_slider, &QSlider::sliderReleased, this, &ImagePropertiesWidget::apply);
- connect (ui->m_scale_sb, &QSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ connect (ui->m_width_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ connect (ui->m_height_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ connect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
}
else
{
- disconnect (ui->m_scale_slider, &QSlider::sliderReleased, this, &ImagePropertiesWidget::apply);
- disconnect (ui->m_scale_sb, &QSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ disconnect (ui->m_width_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ disconnect (ui->m_height_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ disconnect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
+ disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply);
}
return true;
@@ -129,18 +153,44 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit)
/**
@brief ImagePropertiesWidget::associatedUndo
- @return the change in an undo command (ItemResizerCommand).
- If there is no change return nullptr
+ @return the change in an undo command. If there is no change return
+ nullptr. Chains scaleFactorX, scaleFactorY, rotationAngle, skewX and
+ skewY as separate QPropertyUndoCommands, matching the established
+ "check each field, chain if changed" pattern used elsewhere -- each
+ of these is genuinely independent (the resize/rotate/skew handles
+ can set any of them without touching the others), so a single
+ combined command wouldn't correctly capture "only two of five
+ fields actually changed".
*/
QUndoCommand* ImagePropertiesWidget::associatedUndo() const
{
+ const qreal newScaleX = ui->m_width_sb->value() / 100.0;
+ const qreal newScaleY = ui->m_height_sb->value() / 100.0;
+ const qreal newRotation = ui->m_angle_sb->value();
+ const qreal newSkewX = ui->m_skew_x_sb->value();
+ const qreal newSkewY = ui->m_skew_y_sb->value();
+
+ QPropertyUndoCommand *undo = nullptr;
+ auto chain = [&](const char *property, qreal oldValue, qreal newValue, const QString &text)
+ {
+ if (qFuzzyCompare(oldValue, newValue))
+ return;
+ if (undo)
+ new QPropertyUndoCommand(m_image, property, oldValue, newValue, undo);
+ else
+ {
+ undo = new QPropertyUndoCommand(m_image, property, oldValue, newValue);
+ undo->enableAnimation();
+ undo->setText(text);
+ }
+ };
+
+ chain("scaleFactorX", m_scaleX, newScaleX, tr("Modifier la largeur d'une image"));
+ chain("scaleFactorY", m_scaleY, newScaleY, tr("Modifier la hauteur d'une image"));
+ chain("rotationAngle", m_rotation, newRotation, tr("Modifier l'angle d'une image"));
+ chain("skewX", m_skewX, newSkewX, tr("Modifier l'inclinaison d'une image"));
+ chain("skewY", m_skewY, newSkewY, tr("Modifier l'inclinaison d'une image"));
- qreal value = ui->m_scale_slider->value();
- value /= 100;
- if (m_scale == value) return nullptr;
- QPropertyUndoCommand *undo = new QPropertyUndoCommand(m_image, "scale", m_scale, value);
- undo->enableAnimation();
- undo->setText(tr("Modifier la taille d'une image"));
return undo;
}
@@ -150,20 +200,167 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const
*/
void ImagePropertiesWidget::updateUi()
{
- if (!m_image) return;
- ui->m_scale_slider->setValue(m_image->scale() * 100);
+ // Two different uses of the same flag: an entry guard here (skip
+ // entirely if a locked width/height operation is already in
+ // progress elsewhere -- its own explicit updateUi() call once
+ // finished is what actually refreshes things), and, immediately
+ // below, a re-entrancy guard around this function's OWN setValue()
+ // calls, so they can't recurse back into
+ // on_m_width_sb_valueChanged()/on_m_height_sb_valueChanged() when
+ // this runs normally (e.g. from setImageItem(), or a transformChanged
+ // signal from a resize handle used directly on the canvas while
+ // this dialog is open).
+ if (!m_image || m_updating_ratio) return;
+
+ m_updating_ratio = true;
+ ui->m_width_sb->setValue(m_image->scaleFactorX() * 100.0);
+ ui->m_height_sb->setValue(m_image->scaleFactorY() * 100.0);
+ m_updating_ratio = false;
+
+ ui->m_angle_sb->setValue(m_image->rotationAngle());
+ ui->m_skew_x_sb->setValue(m_image->skewX());
+ ui->m_skew_y_sb->setValue(m_image->skewY());
ui->m_lock_pos_cb->setChecked(!m_image->isMovable());
}
/**
- @brief ImagePropertiesWidget::on_m_scale_slider_valueChanged
- Update the size of image when move slider.
- @param value
+ @brief ImagePropertiesWidget::on_m_lock_ratio_tb_toggled
+ Keeps the button's own icon and tooltip in sync with its checked
+ state -- Qt shows the checked/unchecked state visually (a sunken
+ look) regardless, but the lock/unlock glyph makes the meaning
+ obvious at a glance rather than needing that convention noticed.
+ @param checked
*/
-void ImagePropertiesWidget::on_m_scale_slider_valueChanged(int value)
+void ImagePropertiesWidget::on_m_lock_ratio_tb_toggled(bool checked)
{
- qreal scale = value;
- m_image->setScale(scale / 100);
+ ui->m_lock_ratio_tb->setIcon(checked ? QET::Icons::ObjectLocked : QET::Icons::ObjectUnlocked);
+ ui->m_lock_ratio_tb->setToolTip(checked
+ ? tr("Verrouillé : modifier la largeur ou la hauteur ajuste l'autre pour conserver les proportions. Cliquer pour déverrouiller.")
+ : tr("Déverrouillé : largeur et hauteur peuvent être modifiées indépendamment. Cliquer pour verrouiller."));
+}
+
+/**
+ @brief ImagePropertiesWidget::on_m_restore_ratio_pb_clicked
+ The same action as the item's own "Restaurer les proportions"
+ context-menu entry, offered here too since this dialog is exactly
+ where someone would notice the proportions are off in the first
+ place. Deliberately bypasses on_m_height_sb_valueChanged()'s own
+ lock-following logic (guarded by m_updating_ratio, then applied
+ directly rather than through the spinbox's own signal) rather than
+ going through it normally: if the lock happened to be engaged,
+ that logic would treat this as an ordinary edit and scale width by
+ the same ratio the height change just applied -- compounding into
+ some other, unrelated value instead of the plain "make height
+ match width" this button promises regardless of lock state.
+*/
+void ImagePropertiesWidget::on_m_restore_ratio_pb_clicked()
+{
+ if (!m_image) return;
+ const qreal newScaleY = m_image->scaleFactorX();
+ m_updating_ratio = true;
+ ui->m_height_sb->setValue(newScaleY * 100.0);
+ m_updating_ratio = false;
+ m_image->setScaleFactorY(newScaleY);
+ apply();
+}
+
+/**
+ @brief ImagePropertiesWidget::on_m_width_sb_valueChanged
+ Applies the new width live, and -- if "Conserver les proportions" is
+ checked -- scales height by the same ratio this change just applied
+ to width, so the two stay locked together. Guarded by
+ m_updating_ratio: the setValue() call below would otherwise
+ re-trigger on_m_height_sb_valueChanged(), which would then try to
+ scale width again in response, recursing indefinitely.
+ @param value the new width, as a percentage
+*/
+void ImagePropertiesWidget::on_m_width_sb_valueChanged(double value)
+{
+ if (!m_image || m_updating_ratio) return;
+
+ const qreal oldScaleX = m_image->scaleFactorX();
+ const qreal newScaleX = value / 100.0;
+ const bool wasLocked = ui->m_lock_ratio_tb->isChecked();
+
+ // Guards updateUi() itself here, not just the mutual spinbox
+ // updates below: setScaleFactorX() emits transformChanged()
+ // immediately, synchronously calling updateUi() before this
+ // function gets any further -- at that exact moment scaleX has
+ // already changed but scaleY hasn't caught up yet, so updateUi()'s
+ // "auto-unlock if currently mismatched" check would fire and
+ // uncheck the lock before the compensating height change below
+ // ever runs, defeating the lock on every single use. Released
+ // before the explicit updateUi() call at the end, once both axes
+ // genuinely agree again (if locked) and that check is meaningful.
+ m_updating_ratio = true;
+ m_image->setScaleFactorX(newScaleX);
+
+ if (wasLocked && !qFuzzyIsNull(oldScaleX))
+ {
+ const qreal ratio = newScaleX / oldScaleX;
+ const qreal newScaleY = m_image->scaleFactorY() * ratio;
+ ui->m_height_sb->setValue(newScaleY * 100.0);
+ m_image->setScaleFactorY(newScaleY);
+ }
+ m_updating_ratio = false;
+ updateUi();
+}
+
+/**
+ @brief ImagePropertiesWidget::on_m_height_sb_valueChanged
+ Mirror of on_m_width_sb_valueChanged() -- see its comment.
+ @param value the new height, as a percentage
+*/
+void ImagePropertiesWidget::on_m_height_sb_valueChanged(double value)
+{
+ if (!m_image || m_updating_ratio) return;
+
+ const qreal oldScaleY = m_image->scaleFactorY();
+ const qreal newScaleY = value / 100.0;
+ const bool wasLocked = ui->m_lock_ratio_tb->isChecked();
+
+ m_updating_ratio = true; // see on_m_width_sb_valueChanged()'s identical comment
+ m_image->setScaleFactorY(newScaleY);
+
+ if (wasLocked && !qFuzzyIsNull(oldScaleY))
+ {
+ const qreal ratio = newScaleY / oldScaleY;
+ const qreal newScaleX = m_image->scaleFactorX() * ratio;
+ ui->m_width_sb->setValue(newScaleX * 100.0);
+ m_image->setScaleFactorX(newScaleX);
+ }
+ m_updating_ratio = false;
+ updateUi();
+}
+
+/**
+ @brief ImagePropertiesWidget::on_m_angle_sb_valueChanged
+ @param value the new rotation, in degrees
+*/
+void ImagePropertiesWidget::on_m_angle_sb_valueChanged(double value)
+{
+ if (!m_image) return;
+ m_image->setRotationAngle(value);
+}
+
+/**
+ @brief ImagePropertiesWidget::on_m_skew_x_sb_valueChanged
+ @param value the new X skew, in degrees
+*/
+void ImagePropertiesWidget::on_m_skew_x_sb_valueChanged(double value)
+{
+ if (!m_image) return;
+ m_image->setSkewX(value);
+}
+
+/**
+ @brief ImagePropertiesWidget::on_m_skew_y_sb_valueChanged
+ @param value the new Y skew, in degrees
+*/
+void ImagePropertiesWidget::on_m_skew_y_sb_valueChanged(double value)
+{
+ if (!m_image) return;
+ m_image->setSkewY(value);
}
/**
diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h
index 0cd803e89..438b5e3b8 100644
--- a/sources/ui/imagepropertieswidget.h
+++ b/sources/ui/imagepropertieswidget.h
@@ -48,14 +48,33 @@ class ImagePropertiesWidget : public PropertiesEditorWidget
void updateUi() override;
private slots:
- void on_m_scale_slider_valueChanged(int value);
+ void on_m_width_sb_valueChanged(double value);
+ void on_m_height_sb_valueChanged(double value);
+ void on_m_angle_sb_valueChanged(double value);
+ void on_m_skew_x_sb_valueChanged(double value);
+ void on_m_skew_y_sb_valueChanged(double value);
+ void on_m_lock_ratio_tb_toggled(bool checked);
+ void on_m_restore_ratio_pb_clicked();
void on_m_lock_pos_cb_clicked();
private:
Ui::ImagePropertiesWidget *ui;
DiagramImageItem *m_image;
bool m_movable;
- qreal m_scale;
+ // All tracked independently -- the image may already be
+ // non-uniformly scaled, rotated, and/or skewed via the resize/
+ // rotate/skew handles before this dialog is even opened, and
+ // undo/reset have to restore each to whatever it actually was.
+ qreal m_scaleX;
+ qreal m_scaleY;
+ qreal m_rotation;
+ qreal m_skewX;
+ qreal m_skewY;
+ // Guards the width/height spinboxes' mutual updates when
+ // "Conserver les proportions" is checked, so setting one
+ // programmatically in response to the other doesn't re-trigger
+ // its own valueChanged and recurse.
+ bool m_updating_ratio = false;
};
#endif // IMAGEPROPERTIESWIDGET_H
diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui
index f3bd2bcda..36ff7ddf3 100644
--- a/sources/ui/imagepropertieswidget.ui
+++ b/sources/ui/imagepropertieswidget.ui
@@ -6,8 +6,8 @@
00
- 244
- 105
+ 280
+ 230
@@ -17,47 +17,149 @@
QLayout::SetMinimumSize
-
+
+
+
+ Largeur
+
+
+
+
+
+
+ %
+
+
+ 1
+
+
+ 1.000000000000000
+
+
+ 1000.000000000000000
+
+
+
+
+
+
+ Verrouillé : modifier la largeur ou la hauteur ajuste l'autre pour conserver les proportions. Cliquer pour déverrouiller.
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+ Hauteur
+
+
+
+
+
+
+ %
+
+
+ 1
+
+
+ 1.000000000000000
+
+
+ 1000.000000000000000
+
+
+
+
+
+
+ Restaurer les proportions
+
+
+
+
+
+
+ Angle
+
+
+
+
+
+
+ °
+
+
+ 1
+
+
+ -360.000000000000000
+
+
+ 360.000000000000000
+
+
+
+
+
+
+ Inclinaison X
+
+
+
+
+
+
+ °
+
+
+ 1
+
+
+ -85.000000000000000
+
+
+ 85.000000000000000
+
+
+
+
+
+
+ Inclinaison Y
+
+
+
+
+
+
+ °
+
+
+ 1
+
+
+ -85.000000000000000
+
+
+ 85.000000000000000
+
+
+
+ Verrouiller la position
-
-
-
- Dimension de l'image
-
-
-
-
-
-
- 1
-
-
- 400
-
-
- Qt::Horizontal
-
-
-
-
-
-
- %
-
-
- 1
-
-
- 400
-
-
-
-
+ Qt::Vertical
@@ -73,38 +175,5 @@
-
-
- m_scale_slider
- valueChanged(int)
- m_scale_sb
- setValue(int)
-
-
- 81
- 40
-
-
- 190
- 40
-
-
-
-
- m_scale_sb
- valueChanged(int)
- m_scale_slider
- setValue(int)
-
-
- 190
- 40
-
-
- 81
- 40
-
-
-
-
+
diff --git a/sources/ui/imagetransparentcolordialog.cpp b/sources/ui/imagetransparentcolordialog.cpp
new file mode 100644
index 000000000..62bcf8813
--- /dev/null
+++ b/sources/ui/imagetransparentcolordialog.cpp
@@ -0,0 +1,335 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "imagetransparentcolordialog.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+ // Fits the dialog comfortably on a normal screen regardless of the
+ // source image's own resolution -- a photo straight off a phone
+ // would otherwise make this dialog enormous.
+ constexpr int MAX_DISPLAY_SIZE = 350;
+}
+
+/**
+ @brief ClickableImageLabel::ClickableImageLabel
+ @param sourceImage the full-resolution image to display and sample from
+ @param parent
+*/
+ClickableImageLabel::ClickableImageLabel(const QImage &sourceImage, QWidget *parent) :
+ QLabel(parent),
+ m_source(sourceImage)
+{
+ const qreal scaleW = qreal(MAX_DISPLAY_SIZE) / m_source.width();
+ const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height();
+ m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one
+
+ const QImage displayImage = (m_displayScale < 1.0)
+ ? m_source.scaled(m_source.size() * m_displayScale, Qt::KeepAspectRatio, Qt::SmoothTransformation)
+ : m_source;
+ setPixmap(QPixmap::fromImage(displayImage));
+ setCursor(Qt::CrossCursor);
+ setToolTip(tr("Cliquez pour choisir une couleur"));
+}
+
+/**
+ @brief ClickableImageLabel::mousePressEvent
+ Maps the click back to the original, full-resolution image before
+ sampling -- see the class-level comment for why.
+ @param event
+*/
+void ClickableImageLabel::mousePressEvent(QMouseEvent *event)
+{
+ if (event->button() != Qt::LeftButton || pixmap().isNull())
+ return;
+
+ // The label may be larger than its pixmap (layout stretching); the
+ // pixmap itself is always drawn centered by Qt's default alignment,
+ // so the click has to be re-based against the pixmap's own rect
+ // within the label, not the label's own top-left.
+ const QRect pixmapRect(
+ (width() - pixmap().width()) / 2,
+ (height() - pixmap().height()) / 2,
+ pixmap().width(), pixmap().height());
+ if (!pixmapRect.contains(event->pos()))
+ return;
+
+ const QPoint withinPixmap = event->pos() - pixmapRect.topLeft();
+ QPoint originalPos(qRound(withinPixmap.x() / m_displayScale), qRound(withinPixmap.y() / m_displayScale));
+ originalPos.setX(qBound(0, originalPos.x(), m_source.width() - 1));
+ originalPos.setY(qBound(0, originalPos.y(), m_source.height() - 1));
+
+ emit colorPicked(m_source.pixelColor(originalPos));
+}
+
+/**
+ @brief ImageTransparentColorDialog::ImageTransparentColorDialog
+ @param pixmap the image to pick a transparent colour from
+ @param parent
+*/
+ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap, const QList &existingColors,
+ int existingTolerance, QWidget *parent) :
+ QDialog(parent),
+ m_sourceImage(basePixmap.toImage()),
+ m_pickedColors(existingColors),
+ m_tolerance(existingTolerance)
+{
+ setWindowTitle(tr("Couleur transparente"));
+
+ m_sourceLabel = new ClickableImageLabel(m_sourceImage, this);
+ m_previewLabel = new QLabel(this);
+
+ m_hintLabel = new QLabel(this);
+
+ m_toleranceSlider = new QSlider(Qt::Horizontal, this);
+ m_toleranceSlider->setRange(0, 100);
+ m_toleranceSlider->setValue(m_tolerance);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+ m_okButton = buttons->button(QDialogButtonBox::Ok);
+ m_okButton->setEnabled(!m_pickedColors.isEmpty()); // nothing to apply until at least one colour has been picked
+
+ auto *grid = new QGridLayout;
+ grid->addWidget(new QLabel(tr("Image source")), 0, 0);
+ grid->addWidget(new QLabel(tr("Aperçu")), 0, 1);
+ grid->addWidget(m_sourceLabel, 1, 0);
+ grid->addWidget(m_previewLabel, 1, 1);
+
+ // An empty row to start with if existingColors is empty --
+ // rebuildSwatches() below populates it either way (including from
+ // existingColors on the first call), and again as colours get
+ // added or removed.
+ m_swatchesLayout = new QHBoxLayout;
+
+ auto *colorRow = new QHBoxLayout;
+ colorRow->addWidget(m_hintLabel);
+ colorRow->addStretch();
+ colorRow->addLayout(m_swatchesLayout);
+
+ auto *toleranceRow = new QHBoxLayout;
+ toleranceRow->addWidget(new QLabel(tr("Tolérance")));
+ toleranceRow->addWidget(m_toleranceSlider);
+
+ auto *mainLayout = new QVBoxLayout(this);
+ mainLayout->addLayout(grid);
+ mainLayout->addLayout(colorRow);
+ mainLayout->addLayout(toleranceRow);
+ mainLayout->addWidget(buttons);
+
+ connect(m_sourceLabel, &ClickableImageLabel::colorPicked, this, &ImageTransparentColorDialog::onColorPicked);
+ connect(m_toleranceSlider, &QSlider::valueChanged, this, &ImageTransparentColorDialog::onToleranceChanged);
+ connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+
+ rebuildSwatches(); // shows existingColors immediately, if any
+ updatePreview(); // and the preview already reflects them too, rather than only appearing after the next pick
+}
+
+/**
+ @brief ImageTransparentColorDialog::onColorPicked
+ Adds to the set of picked colours rather than replacing the previous
+ pick -- clicking a second, different pixel used to silently discard
+ the first choice, with no way to work with more than one colour (a
+ white background *and* a grey border, say) in the same pass.
+ Skips an exact duplicate rather than adding a second, indistinguishable
+ swatch for it.
+ @param color the colour sampled from the source image
+*/
+void ImageTransparentColorDialog::onColorPicked(const QColor &color)
+{
+ if (m_pickedColors.contains(color))
+ return;
+
+ m_pickedColors.append(color);
+ m_okButton->setEnabled(true);
+ rebuildSwatches();
+ updatePreview();
+}
+
+/**
+ @brief ImageTransparentColorDialog::removeColor
+ Removes one colour from the set -- the counterpart onColorPicked()
+ was missing entirely before: picking the wrong pixel by mistake had
+ no way to undo except cancelling the whole dialog and starting over.
+ @param color the colour to remove
+*/
+void ImageTransparentColorDialog::removeColor(const QColor &color)
+{
+ m_pickedColors.removeAll(color);
+ m_okButton->setEnabled(!m_pickedColors.isEmpty());
+ rebuildSwatches();
+ updatePreview();
+}
+
+/**
+ @brief ImageTransparentColorDialog::rebuildSwatches
+ Rebuilds the row of picked-colour swatches from scratch against the
+ current m_pickedColors -- simpler and safer than trying to
+ incrementally add/remove individual widgets in sync with the list,
+ given the list only ever changes one colour at a time and is never
+ large enough for a full rebuild to be a real cost.
+*/
+void ImageTransparentColorDialog::rebuildSwatches()
+{
+ QLayoutItem *item;
+ while ((item = m_swatchesLayout->takeAt(0)) != nullptr)
+ {
+ delete item->widget();
+ delete item;
+ }
+
+ for (const QColor &color : std::as_const(m_pickedColors))
+ {
+ auto *swatch = new QPushButton(this);
+ swatch->setFixedSize(24, 24);
+ swatch->setStyleSheet(QStringLiteral("background-color: rgb(%1,%2,%3); border: 1px solid palette(mid);")
+ .arg(color.red()).arg(color.green()).arg(color.blue()));
+ swatch->setToolTip(tr("rgb(%1, %2, %3) -- cliquer pour retirer").arg(color.red()).arg(color.green()).arg(color.blue()));
+ connect(swatch, &QPushButton::clicked, this, [this, color]() { removeColor(color); });
+ m_swatchesLayout->addWidget(swatch);
+ }
+
+ m_hintLabel->setText(m_pickedColors.isEmpty()
+ ? tr("Cliquez sur l'image pour choisir une couleur")
+ : tr("Cliquez sur l'image pour ajouter une couleur, ou sur une pastille pour la retirer"));
+}
+
+/**
+ @brief ImageTransparentColorDialog::onToleranceChanged
+ @param value the new tolerance, 0-100
+*/
+void ImageTransparentColorDialog::onToleranceChanged(int value)
+{
+ m_tolerance = value;
+ if (!m_pickedColors.isEmpty())
+ updatePreview();
+}
+
+/**
+ @brief ImageTransparentColorDialog::updatePreview
+ Recomputes the checkerboard-backed preview against the current set
+ of picked colours and the shared tolerance. Always runs against
+ m_sourceImage (the original, full-resolution image), not any
+ already-keyed result -- so adjusting the tolerance, or adding or
+ removing a colour, re-evaluates every picked colour from scratch
+ each time rather than compounding successive passes.
+*/
+void ImageTransparentColorDialog::updatePreview()
+{
+ const QImage keyed = applyColorKey(m_sourceImage, m_pickedColors, m_tolerance);
+ m_previewLabel->setPixmap(onCheckerboard(keyed));
+}
+
+/**
+ @brief ImageTransparentColorDialog::resultPixmap
+ @return the colour-keyed pixmap against every picked colour, or the
+ original pixmap unchanged if none were ever picked (the Ok button
+ stays disabled until at least one is, so this is mostly a defensive
+ fallback).
+*/
+QPixmap ImageTransparentColorDialog::resultPixmap() const
+{
+ if (m_pickedColors.isEmpty())
+ return QPixmap::fromImage(m_sourceImage);
+ return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors, m_tolerance));
+}
+
+/**
+ @brief ImageTransparentColorDialog::applyColorKey
+ Binary transparency within tolerance, not a smooth falloff: every
+ pixel within `tolerance` (0-100, mapped onto the maximum possible
+ RGB distance) of *any* of keyColors becomes fully transparent,
+ everything else keeps its existing alpha untouched. Squared distance
+ throughout, avoiding a sqrt per pixel; breaks out of the inner loop
+ on the first matching colour, since further matches wouldn't change
+ the outcome.
+ @param source the image to key
+ @param keyColors the colours to make transparent
+ @param tolerance 0 (exact match only) to 100 (everything)
+ @return the resulting image, always in Format_ARGB32
+*/
+QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList &keyColors, int tolerance)
+{
+ QImage result = source.convertToFormat(QImage::Format_ARGB32);
+ if (keyColors.isEmpty())
+ return result;
+
+ QVector keys;
+ keys.reserve(keyColors.size());
+ for (const QColor &c : keyColors)
+ keys.append(c.rgb());
+
+ const qint64 threshold = qint64(tolerance) * tolerance * 3 * 255 * 255 / (100 * 100);
+
+ for (int y = 0; y < result.height(); ++y)
+ {
+ QRgb *line = reinterpret_cast(result.scanLine(y));
+ for (int x = 0; x < result.width(); ++x)
+ {
+ const QRgb px = line[x];
+ for (const QRgb &key : keys)
+ {
+ const int dr = qRed(px) - qRed(key), dg = qGreen(px) - qGreen(key), db = qBlue(px) - qBlue(key);
+ const qint64 distSq = qint64(dr) * dr + qint64(dg) * dg + qint64(db) * db;
+ if (distSq <= threshold)
+ {
+ line[x] = qRgba(qRed(px), qGreen(px), qBlue(px), 0);
+ break;
+ }
+ }
+ }
+ }
+ return result;
+}
+
+/**
+ @brief ImageTransparentColorDialog::onCheckerboard
+ Composites `image` over a light/dark checkerboard, the standard
+ visual convention for showing where an image is transparent --
+ without this, a fully keyed-out region would just show whatever
+ widget background happens to be behind it, easy to misread as "still
+ opaque, just white" rather than "correctly transparent".
+ @param image the (possibly partially transparent) image to composite
+ @return a checkerboard-backed pixmap ready to display
+*/
+QPixmap ImageTransparentColorDialog::onCheckerboard(const QImage &image)
+{
+ const int cell = 8;
+ QPixmap board(image.size());
+ QPainter painter(&board);
+ for (int y = 0; y < image.height(); y += cell)
+ {
+ for (int x = 0; x < image.width(); x += cell)
+ {
+ const bool dark = ((x / cell) + (y / cell)) % 2 == 0;
+ painter.fillRect(x, y, cell, cell, dark ? QColor(200, 200, 200) : QColor(255, 255, 255));
+ }
+ }
+ painter.drawImage(0, 0, image);
+ painter.end();
+ return board;
+}
diff --git a/sources/ui/imagetransparentcolordialog.h b/sources/ui/imagetransparentcolordialog.h
new file mode 100644
index 000000000..691983057
--- /dev/null
+++ b/sources/ui/imagetransparentcolordialog.h
@@ -0,0 +1,133 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef IMAGE_TRANSPARENT_COLOR_DIALOG_H
+#define IMAGE_TRANSPARENT_COLOR_DIALOG_H
+
+#include
+#include
+#include
+#include
+#include
+
+class QSlider;
+class QPushButton;
+class QHBoxLayout;
+class QMouseEvent;
+
+/**
+ @brief The ClickableImageLabel class
+ Displays an image scaled to fit a reasonable dialog size, but always
+ samples the picked color from the original, full-resolution image at
+ the corresponding coordinate -- never from the (possibly smoothly
+ interpolated, and therefore color-blended) scaled-down pixmap being
+ displayed, which would make the picked color subtly wrong for
+ exactly the pixels near an edge where it matters most.
+*/
+class ClickableImageLabel : public QLabel
+{
+ Q_OBJECT
+
+ public:
+ explicit ClickableImageLabel(const QImage &sourceImage, QWidget *parent = nullptr);
+
+ signals:
+ void colorPicked(const QColor &color);
+
+ protected:
+ void mousePressEvent(QMouseEvent *event) override;
+
+ private:
+ QImage m_source;
+ qreal m_displayScale = 1.0;
+};
+
+/**
+ @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.
+*/
+class ImageTransparentColorDialog : public QDialog
+{
+ Q_OBJECT
+
+ public:
+ /// @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 &existingColors = {},
+ int existingTolerance = 10, QWidget *parent = nullptr);
+
+ /// The resulting pixmap: basePixmap unchanged if no colour is
+ /// picked, colour-keyed against every picked colour 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 pickedColors() const { return m_pickedColors; }
+ /// The final tolerance, for the same reason.
+ int tolerance() const { return m_tolerance; }
+
+ /// 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 &keyColors, int tolerance);
+
+ private slots:
+ void onColorPicked(const QColor &color);
+ void onToleranceChanged(int value);
+
+ private:
+ void removeColor(const QColor &color);
+ void rebuildSwatches();
+ void updatePreview();
+ static QPixmap onCheckerboard(const QImage &image);
+
+ QImage m_sourceImage;
+ QList m_pickedColors;
+ int m_tolerance = 10;
+
+ ClickableImageLabel *m_sourceLabel;
+ QLabel *m_previewLabel;
+ QHBoxLayout *m_swatchesLayout;
+ QLabel *m_hintLabel;
+ QSlider *m_toleranceSlider;
+ QPushButton *m_okButton;
+};
+
+#endif // IMAGE_TRANSPARENT_COLOR_DIALOG_H
diff --git a/sources/ui/shapegraphicsitempropertieswidget.cpp b/sources/ui/shapegraphicsitempropertieswidget.cpp
index 5d4580321..2efed0bb8 100644
--- a/sources/ui/shapegraphicsitempropertieswidget.cpp
+++ b/sources/ui/shapegraphicsitempropertieswidget.cpp
@@ -73,7 +73,8 @@ void ShapeGraphicsItemPropertiesWidget::setItem(QetShapeItem *shape)
}
m_shape = shape;
- ui->m_close_polygon->setVisible(m_shape->shapeType() == QetShapeItem::Polygon);
+ ui->m_close_polygon->setVisible(m_shape->shapeType() == QetShapeItem::Polygon
+ || m_shape->shapeType() == QetShapeItem::Path);
ui->m_filling_gb->setHidden(m_shape->shapeType() == QetShapeItem::Line);
updateUi();
@@ -164,6 +165,58 @@ QUndoCommand* ShapeGraphicsItemPropertiesWidget::associatedUndo() const
{
QPropertyUndoCommand *undo = nullptr;
+ //Geometry
+ const bool hasGeometry = (m_shape->shapeType() == QetShapeItem::Line
+ || m_shape->shapeType() == QetShapeItem::Rectangle
+ || m_shape->shapeType() == QetShapeItem::Ellipse);
+ if (hasGeometry)
+ {
+ if (m_shape->shapeType() == QetShapeItem::Line)
+ {
+ const QLineF old_line = m_shape->line();
+ QLineF new_line = old_line;
+ new_line.setLength(ui->m_geom_dim1_dsb->value());
+
+ if (new_line != old_line)
+ {
+ undo = new QPropertyUndoCommand(m_shape, "line", old_line, new_line);
+ undo->setText(tr("Modifier la longueur d'une ligne"));
+ }
+ }
+ else
+ {
+ const QRectF old_rect = m_shape->rect().normalized();
+ const bool isEllipse = (m_shape->shapeType() == QetShapeItem::Ellipse);
+ const qreal new_width = isEllipse ? ui->m_geom_dim1_dsb->value() * 2.0 : ui->m_geom_dim1_dsb->value();
+ const qreal new_height = isEllipse ? ui->m_geom_dim2_dsb->value() * 2.0 : ui->m_geom_dim2_dsb->value();
+ // Anchored on the shape's own current top-left, not
+ // re-centered -- matches how the resize handle itself
+ // behaves (the opposite corner stays put while the
+ // dragged one moves), so typing a width/height here
+ // and dragging a handle agree on what "resizing" means.
+ const QRectF new_rect(old_rect.topLeft(), QSizeF(new_width, new_height));
+
+ if (new_rect != old_rect)
+ {
+ undo = new QPropertyUndoCommand(m_shape, "rect", old_rect, new_rect);
+ undo->setText(tr("Modifier la taille d'une forme"));
+ }
+ }
+
+ const qreal old_angle = m_shape->rotation();
+ const qreal new_angle = ui->m_geom_angle_dsb->value();
+ if (qAbs(old_angle - new_angle) > 0.05)
+ {
+ if (undo)
+ new QPropertyUndoCommand(m_shape, "rotation", old_angle, new_angle, undo);
+ else
+ {
+ undo = new QPropertyUndoCommand(m_shape, "rotation", old_angle, new_angle);
+ undo->setText(tr("Modifier l'angle d'une forme"));
+ }
+ }
+ }
+
QPen old_pen = m_shape->pen();
QPen new_pen = old_pen;
@@ -366,6 +419,35 @@ void ShapeGraphicsItemPropertiesWidget::updateUi()
if (m_shape)
{
+ //Geometry
+ const bool hasGeometry = (m_shape->shapeType() == QetShapeItem::Line
+ || m_shape->shapeType() == QetShapeItem::Rectangle
+ || m_shape->shapeType() == QetShapeItem::Ellipse);
+ ui->m_geometry_gb->setVisible(hasGeometry);
+ if (hasGeometry)
+ {
+ ui->m_geom_angle_dsb->setValue(m_shape->rotation());
+
+ if (m_shape->shapeType() == QetShapeItem::Line)
+ {
+ ui->m_geom_dim1_label->setText(tr("Longueur"));
+ ui->m_geom_dim1_dsb->setValue(m_shape->line().length());
+ ui->m_geom_dim2_label->setVisible(false);
+ ui->m_geom_dim2_dsb->setVisible(false);
+ }
+ else
+ {
+ const QRectF r = m_shape->rect().normalized();
+ const bool isEllipse = (m_shape->shapeType() == QetShapeItem::Ellipse);
+ ui->m_geom_dim1_label->setText(isEllipse ? tr("Rayon X") : tr("Largeur"));
+ ui->m_geom_dim2_label->setText(isEllipse ? tr("Rayon Y") : tr("Hauteur"));
+ ui->m_geom_dim1_dsb->setValue(isEllipse ? r.width() / 2.0 : r.width());
+ ui->m_geom_dim2_dsb->setValue(isEllipse ? r.height() / 2.0 : r.height());
+ ui->m_geom_dim2_label->setVisible(true);
+ ui->m_geom_dim2_dsb->setVisible(true);
+ }
+ }
+
//Pen
ui->m_style_cb->setCurrentIndex(static_cast(m_shape->pen().style()) - 1);
ui->m_size_dsb ->setValue(m_shape->pen().widthF());
@@ -373,7 +455,7 @@ void ShapeGraphicsItemPropertiesWidget::updateUi()
ui->m_color_kpb->setColor(m_shape->pen().color());
//Brush
- if (m_shape->shapeType() == QetShapeItem::Polygon)
+ if (m_shape->shapeType() == QetShapeItem::Polygon || m_shape->shapeType() == QetShapeItem::Path)
ui->m_filling_gb->setVisible(m_shape->isClosed());
ui->m_brush_style_cb->setCurrentIndex(static_cast(m_shape->brush().style()));
@@ -384,6 +466,7 @@ void ShapeGraphicsItemPropertiesWidget::updateUi()
}
else if (m_shapes_list.size() >= 2)
{
+ ui->m_geometry_gb->setVisible(false);
ui->m_close_polygon->setHidden(true);
bool same = true;
//Pen
@@ -477,6 +560,15 @@ void ShapeGraphicsItemPropertiesWidget::setUpEditConnection()
if (m_shape || !m_shapes_list.isEmpty())
{
+ m_edit_connection << connect (ui->m_geom_dim1_dsb, QOverload::of(&QDoubleSpinBox::valueChanged),
+ this, &ShapeGraphicsItemPropertiesWidget::apply);
+
+ m_edit_connection << connect (ui->m_geom_dim2_dsb, QOverload::of(&QDoubleSpinBox::valueChanged),
+ this, &ShapeGraphicsItemPropertiesWidget::apply);
+
+ m_edit_connection << connect (ui->m_geom_angle_dsb, QOverload::of(&QDoubleSpinBox::valueChanged),
+ this, &ShapeGraphicsItemPropertiesWidget::apply);
+
m_edit_connection << connect (ui->m_style_cb, QOverload::of(&QComboBox::activated),
this, &ShapeGraphicsItemPropertiesWidget::apply);
@@ -495,14 +587,23 @@ void ShapeGraphicsItemPropertiesWidget::setUpEditConnection()
m_edit_connection << connect (ui->m_close_polygon, &QCheckBox::clicked,
this, &ShapeGraphicsItemPropertiesWidget::apply);
- m_edit_connection << connect (m_shape, &QetShapeItem::penChanged,
- this, &ShapeGraphicsItemPropertiesWidget::updateUi);
+ if (m_shape)
+ {
+ m_edit_connection << connect (m_shape, &QetShapeItem::penChanged,
+ this, &ShapeGraphicsItemPropertiesWidget::updateUi);
- m_edit_connection << connect (m_shape, &QetShapeItem::closeChanged,
- this, &ShapeGraphicsItemPropertiesWidget::updateUi);
+ m_edit_connection << connect (m_shape, &QetShapeItem::closeChanged,
+ this, &ShapeGraphicsItemPropertiesWidget::updateUi);
- m_edit_connection << connect (m_shape, &QetShapeItem::brushChanged,
- this, &ShapeGraphicsItemPropertiesWidget::updateUi);
+ m_edit_connection << connect (m_shape, &QetShapeItem::brushChanged,
+ this, &ShapeGraphicsItemPropertiesWidget::updateUi);
+
+ m_edit_connection << connect (m_shape, &QetShapeItem::geometryChanged,
+ this, &ShapeGraphicsItemPropertiesWidget::updateUi);
+
+ m_edit_connection << connect (m_shape, &QetShapeItem::transformChanged,
+ this, &ShapeGraphicsItemPropertiesWidget::updateUi);
+ }
}
}
diff --git a/sources/ui/shapegraphicsitempropertieswidget.ui b/sources/ui/shapegraphicsitempropertieswidget.ui
index 01e9ae4a3..8d46e874f 100644
--- a/sources/ui/shapegraphicsitempropertieswidget.ui
+++ b/sources/ui/shapegraphicsitempropertieswidget.ui
@@ -14,6 +14,78 @@
Form
+
+
+
+ Géométrie
+
+
+
+
+
+ Largeur
+
+
+
+
+
+
+ 2
+
+
+ 0.010000000000000
+
+
+ 100000.000000000000000
+
+
+
+
+
+
+ Hauteur
+
+
+
+
+
+
+ 2
+
+
+ 0.010000000000000
+
+
+ 100000.000000000000000
+
+
+
+
+
+
+ Angle
+
+
+
+
+
+
+ 1
+
+
+ -360.000000000000000
+
+
+ 360.000000000000000
+
+
+ °
+
+
+
+
+
+
@@ -230,7 +302,7 @@
- Polygone fermé
+ Forme fermée
@@ -257,6 +329,9 @@
+ m_geom_dim1_dsb
+ m_geom_dim2_dsb
+ m_geom_angle_dsbm_style_cbm_size_dsbm_color_kpb
diff --git a/sources/undocommand/promoteshapecommand.cpp b/sources/undocommand/promoteshapecommand.cpp
new file mode 100644
index 000000000..0983d3f64
--- /dev/null
+++ b/sources/undocommand/promoteshapecommand.cpp
@@ -0,0 +1,43 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "promoteshapecommand.h"
+#include "../qetgraphicsitem/qetshapeitem.h"
+
+PromoteShapeCommand::PromoteShapeCommand(
+ QetShapeItem *shape,
+ const QDomElement &priorStateXml,
+ const QDomElement &newStateXml,
+ QUndoCommand *parent) :
+ QUndoCommand(QObject::tr("Transformer %1").arg(shape ? shape->name() : QString()), parent),
+ m_shape(shape)
+{
+ m_priorDoc.appendChild(m_priorDoc.importNode(priorStateXml, true));
+ m_newDoc.appendChild(m_newDoc.importNode(newStateXml, true));
+}
+
+void PromoteShapeCommand::undo()
+{
+ if (m_shape)
+ m_shape->fromXml(m_priorDoc.documentElement());
+}
+
+void PromoteShapeCommand::redo()
+{
+ if (m_shape)
+ m_shape->fromXml(m_newDoc.documentElement());
+}
diff --git a/sources/undocommand/promoteshapecommand.h b/sources/undocommand/promoteshapecommand.h
new file mode 100644
index 000000000..1cca6eba6
--- /dev/null
+++ b/sources/undocommand/promoteshapecommand.h
@@ -0,0 +1,62 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef PROMOTESHAPECOMMAND_H
+#define PROMOTESHAPECOMMAND_H
+
+#include
+#include
+#include
+
+class QetShapeItem;
+
+/**
+ @brief The PromoteShapeCommand class
+ Used whenever a shape's *identity* changes -- a Rectangle's corner is
+ dragged independently and it becomes a Polygon, an Arc is explicitly
+ converted to a free-form Path, etc.
+
+ The saved .qet file never records what a shape used to be; that fact
+ only needs to survive as long as the rest of the edit history does, so
+ it lives entirely here, as a full XML snapshot on each side. Undo
+ restores the exact prior shape -- type, geometry and transform alike --
+ by round-tripping through the same fromXml() every other load uses.
+ Once the file is saved and reopened, the promotion is permanent, same
+ as any other undo history.
+*/
+class PromoteShapeCommand : public QUndoCommand
+{
+ public:
+ PromoteShapeCommand(
+ QetShapeItem *shape,
+ const QDomElement &priorStateXml,
+ const QDomElement &newStateXml,
+ QUndoCommand *parent = nullptr);
+
+ void undo() override;
+ void redo() override;
+
+ private:
+ QPointer m_shape;
+ // Deep-cloned into private documents at construction time: a
+ // QDomElement is only a reference into whatever QDomDocument it
+ // came from, and that document (typically a short-lived one built
+ // just to call toXml()) is not guaranteed to outlive this command.
+ QDomDocument m_priorDoc, m_newDoc;
+};
+
+#endif // PROMOTESHAPECOMMAND_H