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.svgz ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-project.svgz ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-titleblock.svgz + ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg ico/16x16/object-group.png ico/mac_icon/elmt.icns ico/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 @@ 0 0 - 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_dsb m_style_cb m_size_dsb m_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