mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-28 04:54:13 +02:00
3a50cd9b0e
Now that they carry a uuid, the folio's drawing furniture gets rows of its own: shape, independent_text and image, plus drawing_item_view, which finds any of them by uuid without knowing its kind first and says which folio it is on. Rows follow edits, not just loads. The script API's query() and every other reader go through newQuery() without a rebuild, so an item's row is queued on each change (moves, restyles, text edits, uuid renewal) and the queue is flushed by newQuery() and updateDB() -- a queued write is a set insertion, which matters for a drag that moves hundreds of items per mouse step. A pasted copy joins its folio still carrying its source's uuid and is only renewed afterwards, so a flush in between must not let the copy overwrite its source's row. Each row remembers the item that wrote it; another item with the same uuid waits until uuidChanged() says it has its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3238 lines
111 KiB
C++
3238 lines
111 KiB
C++
/*
|
|
Copyright 2006-2026 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "qetshapeitem.h"
|
|
|
|
#include "../PropertiesEditor/propertieseditordialog.h"
|
|
#include "../QPropertyUndoCommand/qpropertyundocommand.h"
|
|
#include "../QetGraphicsItemModeler/qetgraphicshandlerutility.h"
|
|
#include "../createdxf.h"
|
|
#include "../diagram.h"
|
|
#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 <QActionGroup>
|
|
#include <QCursor>
|
|
#include <QMenu>
|
|
#include <QStatusBar>
|
|
#include <QTimer>
|
|
#include <QToolTip>
|
|
#include <QtCore/qmath.h>
|
|
#include <utility>
|
|
|
|
/**
|
|
@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, polygon, path)
|
|
@param parent parent item
|
|
*/
|
|
QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem *parent) :
|
|
QetGraphicsItem(parent),
|
|
m_shapeType(type),
|
|
m_P1 (p1),
|
|
m_P2 (p2),
|
|
m_hovered(false)
|
|
{
|
|
if (type == Polygon) m_polygon << m_P1 << m_P2;
|
|
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]()
|
|
{
|
|
for(QetGraphicsHandlerItem *qghi : m_handler_vector)
|
|
qghi->setZValue(this->zValue()+1);
|
|
});
|
|
|
|
m_insert_point = new QAction(tr("Ajouter un point"), this);
|
|
m_insert_point->setIcon(QET::Icons::Add);
|
|
connect(m_insert_point, &QAction::triggered, this, &QetShapeItem::insertPoint);
|
|
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()
|
|
{
|
|
if(!m_handler_vector.isEmpty())
|
|
qDeleteAll(m_handler_vector);
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setPen
|
|
Set the pen to use for draw the shape
|
|
@param pen
|
|
*/
|
|
void QetShapeItem::setPen(const QPen &pen)
|
|
{
|
|
if (m_pen == pen) return;
|
|
m_pen = pen;
|
|
update();
|
|
emit penChanged();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setBrush
|
|
Set the brush to use for the fill the shape
|
|
@param brush
|
|
*/
|
|
void QetShapeItem::setBrush(const QBrush &brush)
|
|
{
|
|
if (m_brush == brush) return;
|
|
m_brush = brush;
|
|
update();
|
|
emit brushChanged();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setP2
|
|
Set the second point of this item.
|
|
If this item is a polyline,
|
|
the last point of the polyline is replaced by P2.
|
|
@param P2
|
|
*/
|
|
void QetShapeItem::setP2(const QPointF &P2)
|
|
{
|
|
if (m_shapeType == Polygon && m_polygon.last() != P2)
|
|
{
|
|
prepareGeometryChange();
|
|
m_polygon.replace(m_polygon.size()-1, P2);
|
|
}
|
|
else if (P2 != m_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();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setLine
|
|
Set item geometry to line (only available for line shape)
|
|
@param line
|
|
@return : true when shape is a Line, else false
|
|
*/
|
|
bool QetShapeItem::setLine(const QLineF &line)
|
|
{
|
|
if (Q_UNLIKELY(m_shapeType != Line)) return false;
|
|
prepareGeometryChange();
|
|
m_P1 = line.p1();
|
|
m_P2 = line.p2();
|
|
if (!m_pivotIsCustom)
|
|
resetPivotToBoundingRectCenter();
|
|
repositionHandles();
|
|
emit geometryChanged();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setRect
|
|
Set this item geometry to rect (only available if shape is a rectangle or an ellipse)
|
|
@param rect : new rect
|
|
@return : true when shape is rectangle or ellipse, else false
|
|
*/
|
|
bool QetShapeItem::setRect(const QRectF &rect)
|
|
{
|
|
if (Q_LIKELY(m_shapeType == Rectangle || m_shapeType == Ellipse))
|
|
{
|
|
prepareGeometryChange();
|
|
m_P1 = rect.topLeft();
|
|
m_P2 = rect.bottomRight();
|
|
if (!m_pivotIsCustom)
|
|
resetPivotToBoundingRectCenter();
|
|
repositionHandles();
|
|
emit geometryChanged();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setPolygon
|
|
Set this item geometry to polygon (only available if shape is a polyline)
|
|
@param polygon : new polygon
|
|
@return true if item is polygon, else false
|
|
*/
|
|
bool QetShapeItem::setPolygon(const QPolygonF &polygon)
|
|
{
|
|
if (Q_UNLIKELY(m_shapeType != Polygon)) {
|
|
return false;
|
|
}
|
|
prepareGeometryChange();
|
|
m_polygon = polygon;
|
|
if (!m_pivotIsCustom)
|
|
resetPivotToBoundingRectCenter();
|
|
repositionHandles();
|
|
emit geometryChanged();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setClosed
|
|
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 || m_shapeType == Path) && close != m_closed)
|
|
{
|
|
prepareGeometryChange();
|
|
m_closed = close;
|
|
emit closeChanged();
|
|
}
|
|
}
|
|
|
|
void QetShapeItem::setXRadius(qreal X)
|
|
{
|
|
m_xRadius = X;
|
|
update();
|
|
repositionHandles();
|
|
emit XRadiusChanged();
|
|
}
|
|
|
|
void QetShapeItem::setYRadius(qreal Y)
|
|
{
|
|
m_yRadius = Y;
|
|
update();
|
|
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<PathNode> &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
|
|
*/
|
|
int QetShapeItem::pointsCount() const
|
|
{
|
|
return m_polygon.size();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::setNextPoint
|
|
Add a new point to the current polygon
|
|
@param P the new point.
|
|
*/
|
|
void QetShapeItem::setNextPoint(QPointF P)
|
|
{
|
|
prepareGeometryChange();
|
|
m_polygon.append(Diagram::snapToGrid(P));
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::removePoints
|
|
Number of point to remove on the polygon
|
|
If number is superior to number of polygon points-2,
|
|
all points of polygon will be removed except
|
|
the first two (minimum point for the polygon);
|
|
*/
|
|
void QetShapeItem::removePoints(int number)
|
|
{
|
|
if (pointsCount() == 2 || number < 1) return;
|
|
if ((pointsCount()-2) < number)
|
|
number = pointsCount() - 2;
|
|
|
|
int i = 0;
|
|
do
|
|
{
|
|
i++;
|
|
prepareGeometryChange();
|
|
m_polygon.pop_back();
|
|
|
|
} while (i < number);
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::boundingRect
|
|
@return the bounding rect of this item
|
|
*/
|
|
QRectF QetShapeItem::boundingRect() const
|
|
{
|
|
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::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.
|
|
*/
|
|
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;
|
|
|
|
switch (m_shapeType)
|
|
{
|
|
case Line:
|
|
path.moveTo(m_P1);
|
|
path.lineTo(m_P2);
|
|
break;
|
|
|
|
case Rectangle:
|
|
path.addRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
|
|
break;
|
|
|
|
case Ellipse:
|
|
{
|
|
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);
|
|
return pps.createStroke(outline());
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::paint
|
|
Paint this item
|
|
@param painter
|
|
@param option
|
|
@param widget
|
|
*/
|
|
void QetShapeItem::paint(
|
|
QPainter *painter,
|
|
const QStyleOptionGraphicsItem *option,
|
|
QWidget *widget)
|
|
{
|
|
Q_UNUSED(option)
|
|
Q_UNUSED(widget)
|
|
|
|
painter->save();
|
|
painter->setRenderHint(QPainter::Antialiasing, true);
|
|
painter->setPen(m_pen);
|
|
painter->setBrush(m_brush);
|
|
|
|
//Draw hovered shadow
|
|
if (m_hovered)
|
|
{
|
|
painter->save();
|
|
QColor color(Qt::darkBlue);
|
|
color.setAlpha(50);
|
|
painter -> setBrush (QBrush (color));
|
|
painter -> setPen (Qt::NoPen);
|
|
painter -> drawPath (shape());
|
|
painter -> restore ();
|
|
}
|
|
|
|
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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::hoverEnterEvent
|
|
Handle hover enter event
|
|
@param event
|
|
*/
|
|
void QetShapeItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event)
|
|
{
|
|
m_hovered = true;
|
|
refreshInteractionHints();
|
|
QetGraphicsItem::hoverEnterEvent(event);
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::hoverLeaveEvent
|
|
Handle hover leave event
|
|
@param 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)
|
|
{
|
|
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<PathNode> 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
|
|
@param value
|
|
@return
|
|
*/
|
|
QVariant QetShapeItem::itemChange(QGraphicsItem::GraphicsItemChange change,
|
|
const QVariant &value)
|
|
{
|
|
if (change == ItemSelectedHasChanged)
|
|
{
|
|
if (value.toBool() == true) {
|
|
//If this is selected, we add handlers.
|
|
rebuildHandles();
|
|
}
|
|
else //Else this is deselected, we remove handlers
|
|
{
|
|
if(!m_handler_vector.isEmpty())
|
|
{
|
|
qDeleteAll(m_handler_vector);
|
|
m_handler_vector.clear();
|
|
m_handleRoles.clear();
|
|
m_handleSlot.clear();
|
|
}
|
|
// 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 || change == ItemTransformHasChanged) {
|
|
if (!m_deferHandleReposition)
|
|
repositionHandles();
|
|
}
|
|
else if (change == ItemSceneHasChanged)
|
|
{
|
|
if (!scene()) //This is removed from scene, then we deselect this, and so, the handlers is also removed.
|
|
{
|
|
setSelected(false);
|
|
}
|
|
}
|
|
|
|
return QGraphicsItem::itemChange(change, value);
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::sceneEventFilter
|
|
@param watched
|
|
@param event
|
|
@return
|
|
*/
|
|
bool QetShapeItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event)
|
|
{
|
|
//Watched must be an handler
|
|
if(watched->type() == QetGraphicsHandlerItem::Type)
|
|
{
|
|
QetGraphicsHandlerItem *qghi = qgraphicsitem_cast<QetGraphicsHandlerItem *>(watched);
|
|
|
|
if(m_handler_vector.contains(qghi)) //Handler must be in m_vector_index, then we can start resize
|
|
{
|
|
m_vector_index = m_handler_vector.indexOf(qghi);
|
|
if (m_vector_index != -1)
|
|
{
|
|
if(event->type() == QEvent::GraphicsSceneMousePress) //Click
|
|
{
|
|
handlerMousePressEvent(m_vector_index);
|
|
return true;
|
|
}
|
|
else if(event->type() == QEvent::GraphicsSceneMouseMove) //Move
|
|
{
|
|
handlerMouseMoveEvent(m_vector_index, static_cast<QGraphicsSceneMouseEvent *>(event));
|
|
return true;
|
|
}
|
|
else if (event->type() == QEvent::GraphicsSceneMouseRelease) //Release
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::contextMenuEvent
|
|
@param event
|
|
*/
|
|
void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
|
|
{
|
|
m_context_menu_pos = event->pos();
|
|
|
|
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);
|
|
}
|
|
|
|
if (isSelected() && scene()->selectedItems().size() == 1)
|
|
{
|
|
if (diagram())
|
|
{
|
|
DiagramView *d_view = nullptr;
|
|
for (QGraphicsView *view : diagram()->views())
|
|
{
|
|
if (view->isActiveWindow())
|
|
{
|
|
d_view = dynamic_cast<DiagramView *>(view);
|
|
if (d_view)
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (d_view)
|
|
{
|
|
QScopedPointer<QMenu> menu(new QMenu());
|
|
|
|
if (m_shapeType == QetShapeItem::Polygon)
|
|
{
|
|
menu.data()->addAction(m_insert_point);
|
|
|
|
if (m_handler_vector.count() > 2)
|
|
{
|
|
for (QetGraphicsHandlerItem *qghi : m_handler_vector)
|
|
{
|
|
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<int, qreal> 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)
|
|
{
|
|
// Only a sharp-cornered rectangle converts losslessly to
|
|
// straight-edged polyline -- an ellipse (or arc) flattened
|
|
// to its bounding rect's 4 corners wouldn't resemble the
|
|
// original shape at all, and rounded corners would be
|
|
// silently squared off. Both of those instead need an
|
|
// actual Bezier curve to preserve their real geometry; the
|
|
// label reflects whichever this particular shape will
|
|
// actually get, rather than always claiming "polyligne"
|
|
// regardless of what's about to happen.
|
|
const bool needsBezier = (m_shapeType == Ellipse) || (m_xRadius > 0 || m_yRadius > 0);
|
|
QAction *convert = menu.data()->addAction(needsBezier ? tr("Convertir en courbe de Bézier") : tr("Convertir en polyligne"));
|
|
if(needsBezier && m_shapeType == Rectangle) {
|
|
convert->setIcon(QET::Icons::RectToBezier);
|
|
}
|
|
else if(needsBezier && m_shapeType == Ellipse) {
|
|
convert->setIcon(QET::Icons::EllipseToBezier);
|
|
}
|
|
else {
|
|
convert->setIcon(QET::Icons::RectToPolyline);
|
|
}
|
|
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathOrPolygon);
|
|
}
|
|
|
|
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
|
|
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
|
|
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
|
|
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
|
|
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
|
|
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
|
|
|
|
//menu.data()->addSeparator();
|
|
//QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
|
//connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
|
|
|
|
menu.data()->addSeparator();
|
|
menu.data()->addActions(d_view->contextMenuActions());
|
|
menu.data()->exec(event->screenPos());
|
|
event->accept();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QetGraphicsItem::contextMenuEvent(event);
|
|
}
|
|
|
|
/**
|
|
@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.
|
|
*/
|
|
/**
|
|
@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 == Rectangle)
|
|
{
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|
|
QPointF QetShapeItem::rotateHandleReference(int slot) const
|
|
{
|
|
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)
|
|
{
|
|
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::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.
|
|
*/
|
|
QPointF QetShapeItem::handlePositionFor(HandleRole role, int slot) const
|
|
{
|
|
const QRectF r = localRect();
|
|
|
|
switch (role)
|
|
{
|
|
case HandleRole::Resize:
|
|
if (m_shapeType == Line)
|
|
return (slot == 0) ? m_P1 : m_P2;
|
|
return QetGraphicsHandlerUtility::pointsForRect(r).value(slot);
|
|
|
|
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();
|
|
}
|
|
return QPointF();
|
|
}
|
|
|
|
QVector<QPointF> QetShapeItem::currentHandlePositions() const
|
|
{
|
|
QVector<QPointF> 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();
|
|
}
|
|
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<QPointF> 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<QPointF> positions = currentHandlePositions();
|
|
if (positions.size() != m_handler_vector.size())
|
|
{
|
|
rebuildHandles();
|
|
return;
|
|
}
|
|
|
|
const QVector<QPointF> scenePositions = mapToScene(positions);
|
|
for (int i = 0; i < scenePositions.size(); ++i)
|
|
m_handler_vector.at(i)->setPos(scenePositions.at(i));
|
|
}
|
|
|
|
void QetShapeItem::insertPoint()
|
|
{
|
|
if (m_shapeType == QetShapeItem::Polygon)
|
|
{
|
|
QPolygonF new_polygon = QetGraphicsHandlerUtility::polygonForInsertPoint(this->polygon(), m_closed, Diagram::snapToGrid(m_context_menu_pos));
|
|
|
|
if(new_polygon != m_polygon)
|
|
{
|
|
//Wrap the undo for avoid to merge the undo commands when user add several points.
|
|
QUndoCommand *undo = new QUndoCommand(tr("Ajouter un point à un polygone"));
|
|
new QPropertyUndoCommand(this, "polygon", m_polygon, new_polygon, undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
}
|
|
}
|
|
|
|
void QetShapeItem::removePoint()
|
|
{
|
|
if (m_shapeType != QetShapeItem::Polygon) {
|
|
return;
|
|
}
|
|
|
|
if (m_handler_vector.size() == 2) {
|
|
return;
|
|
}
|
|
|
|
QPointF point = mapToScene(m_context_menu_pos);
|
|
int index = -1;
|
|
for (int i=0 ; i<m_handler_vector.size() ; i++)
|
|
{
|
|
QetGraphicsHandlerItem *qghi = m_handler_vector.at(i);
|
|
if (qghi->contains(qghi->mapFromScene(point)))
|
|
{
|
|
index = i;
|
|
break;
|
|
}
|
|
}
|
|
if (index > -1 && index<m_handler_vector.count())
|
|
{
|
|
QPolygonF polygon = this->polygon();
|
|
polygon.removeAt(index);
|
|
|
|
//Wrap the undo for avoid to merge the undo commands when user add several points.
|
|
QUndoCommand *undo = new QUndoCommand(tr("Supprimer un point d'un polygone"));
|
|
new QPropertyUndoCommand(this, "polygon", this->polygon(), polygon, undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
}
|
|
|
|
/**
|
|
@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).
|
|
*/
|
|
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<int,int> 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<int, qreal> 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();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::convertToPathOrPolygon
|
|
Context-menu action, explicitly requested rather than triggered by an
|
|
Alt+drag (see promoteRectangleOrEllipseToPolygon() for that separate
|
|
mechanism). A sharp-cornered rectangle converts losslessly to a
|
|
4-corner polygon -- its 4 corners already are exactly what a
|
|
rectangle is. Anything else offered here (an ellipse/arc, or a
|
|
rectangle with rounded corners) used to get the exact same
|
|
treatment, which is wrong: an ellipse flattened to its bounding
|
|
rect's 4 corners doesn't resemble an ellipse at all, and rounded
|
|
corners would be silently squared off. Both instead become a
|
|
genuine Bezier Path, built from the two verified helper functions
|
|
below (numerically confirmed against Qt's own arcTo()/addRoundedRect()
|
|
output -- worst-case error a small fraction of a unit on shapes
|
|
roughly 100-250 units across, i.e. visually indistinguishable, not
|
|
merely "close enough to eyeball").
|
|
*/
|
|
void QetShapeItem::convertToPathOrPolygon()
|
|
{
|
|
if (m_shapeType != Rectangle && m_shapeType != Ellipse)
|
|
return;
|
|
|
|
const bool hasRoundedCorners = (m_shapeType == Rectangle) && (m_xRadius > 0 || m_yRadius > 0);
|
|
// Captured before m_shapeType changes below -- name() reports
|
|
// whatever the CURRENT type is, and by the time the undo text is
|
|
// built further down, m_shapeType has already become Polygon or
|
|
// Path, so calling name() at that point would describe the shape's
|
|
// new type, not what it actually was before conversion ("Convertir
|
|
// une polyligne en polyligne" instead of "Convertir un rectangle en
|
|
// polyligne").
|
|
const QString originalName = name();
|
|
const QDomElement before = snapshotXml();
|
|
|
|
prepareGeometryChange();
|
|
|
|
if (m_shapeType == Rectangle && !hasRoundedCorners)
|
|
{
|
|
QPolygonF corners;
|
|
const QRectF r = localRect();
|
|
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
|
m_shapeType = Polygon;
|
|
m_polygon = corners;
|
|
m_closed = true;
|
|
}
|
|
else if (m_shapeType == Rectangle) // rounded corners
|
|
{
|
|
m_nodes = bezierNodesForRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
|
|
m_shapeType = Path;
|
|
m_closed = true;
|
|
}
|
|
else // Ellipse, full or arc
|
|
{
|
|
const QRectF r(m_P1, m_P2);
|
|
if (isFullEllipse())
|
|
{
|
|
m_nodes = bezierNodesForArc(r, 0, 360);
|
|
m_closed = true;
|
|
}
|
|
else if (m_arcClosure == Pie)
|
|
{
|
|
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
|
|
PathNode centerNode;
|
|
centerNode.anchor = r.center();
|
|
m_nodes.prepend(centerNode);
|
|
m_closed = true;
|
|
}
|
|
else
|
|
{
|
|
// Chord leaves the wrap-around segment's handles unset, which
|
|
// falls through to QPainterPath::closeSubpath()'s own implicit
|
|
// straight line -- exactly the chord behaviour -- when m_closed
|
|
// is true; NoClosure is the same node list, just left open.
|
|
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
|
|
m_closed = (m_arcClosure == Chord);
|
|
}
|
|
m_shapeType = Path;
|
|
}
|
|
|
|
const QDomElement after = snapshotXml();
|
|
|
|
if (diagram())
|
|
{
|
|
auto *undo = new PromoteShapeCommand(this, before, after);
|
|
undo->setText(m_shapeType == Path
|
|
? tr("Convertir %1 en courbe de Bézier").arg(originalName)
|
|
: tr("Convertir %1 en polyligne").arg(originalName));
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
|
|
rebuildHandles();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::bezierNodesForArc
|
|
Bezier approximation of an elliptical arc, split into <=90-degree
|
|
segments (the standard cap for this technique to stay visually
|
|
exact -- a single segment starts drifting noticeably past that).
|
|
Each segment's handle length is (4/3)*tan(segment_span/4), the
|
|
general-angle form of the well-known ~0.5523 "kappa" constant for
|
|
an exact quarter circle. A full 360-degree span is a special case:
|
|
it produces exactly `segments` nodes rather than `segments+1`
|
|
(the very last one would just be a duplicate of the first at the
|
|
same anchor point) and wires the wrap-around segment's own handles
|
|
explicitly, so the loop closes as a continuous curve rather than
|
|
the straight "chord" line a generic closed-path wrap-around would
|
|
otherwise fall back to.
|
|
@param rect the ellipse's bounding rect
|
|
@param startAngleDeg arc start angle, matching QPainterPath::arcTo's own convention
|
|
@param spanAngleDeg arc angular span, same convention (may be negative)
|
|
@return nodes forming the arc; caller sets Path/m_closed and prepends
|
|
a centre node itself for a Pie-style closure
|
|
*/
|
|
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg)
|
|
{
|
|
const bool fullLoop = qFuzzyCompare(qAbs(spanAngleDeg), 360.0);
|
|
const qreal cx = rect.center().x(), cy = rect.center().y();
|
|
const qreal rx = rect.width() / 2.0, ry = rect.height() / 2.0;
|
|
|
|
auto pointAt = [&](qreal angleDeg) {
|
|
const qreal a = qDegreesToRadians(angleDeg);
|
|
return QPointF(cx + rx * qCos(a), cy - ry * qSin(a));
|
|
};
|
|
auto tangentAt = [&](qreal angleDeg) {
|
|
const qreal a = qDegreesToRadians(angleDeg);
|
|
return QPointF(-rx * qSin(a), -ry * qCos(a));
|
|
};
|
|
|
|
const int segments = qMax(1, int(qCeil(qAbs(spanAngleDeg) / 90.0)));
|
|
const qreal segSpan = spanAngleDeg / segments;
|
|
const qreal handleLen = (4.0 / 3.0) * qTan(qDegreesToRadians(qAbs(segSpan)) / 4.0);
|
|
|
|
const int nodeCount = fullLoop ? segments : segments + 1;
|
|
QVector<PathNode> nodes(nodeCount);
|
|
for (int i = 0; i < nodeCount; ++i)
|
|
{
|
|
nodes[i].anchor = pointAt(startAngleDeg + i * segSpan);
|
|
nodes[i].kind = NodeKind::Smooth;
|
|
}
|
|
for (int i = 0; i < nodeCount; ++i)
|
|
{
|
|
const QPointF tangent = tangentAt(startAngleDeg + i * segSpan);
|
|
if (fullLoop || i < nodeCount - 1)
|
|
nodes[i].outHandle = tangent * handleLen;
|
|
if (fullLoop || i > 0)
|
|
nodes[i % nodeCount].inHandle = -tangent * handleLen;
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::bezierNodesForRoundedRect
|
|
Bezier equivalent of QPainterPath::addRoundedRect(): 8 anchors (each
|
|
edge's two ends), straight lines along the 4 edges, and a Bezier
|
|
quarter-turn at each of the 4 corners using the same handle-length
|
|
formula as bezierNodesForArc() (with segSpan fixed at 90 degrees,
|
|
since a rounded rect's corners always are). xRadius/yRadius are
|
|
clamped to at most half the rect's own width/height, matching how
|
|
QPainterPath::addRoundedRect() itself behaves for an
|
|
over-large radius.
|
|
@param rect the rectangle's own corner points, as a QRectF
|
|
@param xRadius corner radius along the x axis
|
|
@param yRadius corner radius along the y axis
|
|
@return 8 nodes forming the closed rounded-rectangle outline
|
|
*/
|
|
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius)
|
|
{
|
|
const QRectF r = rect.normalized();
|
|
const qreal rx = qBound(0.0, xRadius, r.width() / 2.0);
|
|
const qreal ry = qBound(0.0, yRadius, r.height() / 2.0);
|
|
const qreal kx = rx * 0.5522847498;
|
|
const qreal ky = ry * 0.5522847498;
|
|
|
|
const QVector<QPointF> anchors = {
|
|
QPointF(r.left() + rx, r.top()),
|
|
QPointF(r.right() - rx, r.top()),
|
|
QPointF(r.right(), r.top() + ry),
|
|
QPointF(r.right(), r.bottom() - ry),
|
|
QPointF(r.right() - rx, r.bottom()),
|
|
QPointF(r.left() + rx, r.bottom()),
|
|
QPointF(r.left(), r.bottom() - ry),
|
|
QPointF(r.left(), r.top() + ry),
|
|
};
|
|
|
|
QVector<PathNode> nodes;
|
|
nodes.reserve(8);
|
|
for (const QPointF &p : anchors)
|
|
{
|
|
PathNode n;
|
|
n.anchor = p;
|
|
n.kind = NodeKind::Smooth;
|
|
nodes.append(n);
|
|
}
|
|
|
|
nodes[1].outHandle = QPointF(kx, 0); nodes[2].inHandle = QPointF(0, -ky);
|
|
nodes[3].outHandle = QPointF(0, ky); nodes[4].inHandle = QPointF(kx, 0);
|
|
nodes[5].outHandle = QPointF(-kx, 0); nodes[6].inHandle = QPointF(0, ky);
|
|
nodes[7].outHandle = QPointF(0, -ky); nodes[0].inHandle = QPointF(-kx, 0);
|
|
|
|
return nodes;
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::mirror
|
|
Flips the shape around its own current pivot -- horizontal negates
|
|
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(QRectF(m_old_P1, m_old_P2).normalized(), 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_startAngle = m_startAngle;
|
|
m_old_endAngle = m_endAngle;
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::handlerMouseMoveEvent
|
|
@param handlerIndex
|
|
@param event
|
|
*/
|
|
void QetShapeItem::handlerMouseMoveEvent(int handlerIndex, QGraphicsSceneMouseEvent *event)
|
|
{
|
|
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);
|
|
|
|
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 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 handlerIndex
|
|
*/
|
|
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())
|
|
return;
|
|
|
|
QUndoCommand *undo = nullptr;
|
|
|
|
switch (role)
|
|
{
|
|
case HandleRole::Resize:
|
|
if (m_shapeType == Line)
|
|
{
|
|
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));
|
|
}
|
|
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());
|
|
}
|
|
if (undo)
|
|
undo->setText(tr("Redimensionner %1").arg(name()));
|
|
break;
|
|
|
|
case HandleRole::Rotate:
|
|
if (!qFuzzyCompare(m_transform.rotation, m_old_transform.rotation))
|
|
{
|
|
undo = new QPropertyUndoCommand(this, "rotation", m_old_transform.rotation, m_transform.rotation);
|
|
undo->setText(tr("Faire pivoter %1").arg(name()));
|
|
}
|
|
break;
|
|
|
|
case HandleRole::SkewEdge:
|
|
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);
|
|
if (undo)
|
|
undo->setText(tr("Incliner %1").arg(name()));
|
|
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);
|
|
undo->setText(tr("Arrondir les coins d'%1").arg(name()));
|
|
}
|
|
break;
|
|
|
|
case HandleRole::ArcEndpoint:
|
|
// The snap-to-full-ellipse behaviour in setStartAngle()/
|
|
// setEndAngle() (see anglesGeometricallyAdjacent()) can
|
|
// reset BOTH angles at once even though only one endpoint
|
|
// was actually dragged, so both are checked here regardless
|
|
// of which handle (slot 0 or 1) triggered this -- the same
|
|
// reasoning as CornerRadius checking both xRadius and
|
|
// yRadius above.
|
|
if (!qFuzzyCompare(m_startAngle, m_old_startAngle))
|
|
{
|
|
undo = new QPropertyUndoCommand(this, "startAngle", m_old_startAngle, m_startAngle);
|
|
if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
|
|
new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle, undo);
|
|
}
|
|
else if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
|
|
{
|
|
undo = new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle);
|
|
}
|
|
if (undo)
|
|
undo->setText(tr("Modifier l'angle d'un arc"));
|
|
break;
|
|
|
|
case HandleRole::PathAnchor:
|
|
if (m_shapeType == Polygon && m_polygon != m_old_polygon)
|
|
{
|
|
undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon);
|
|
undo->setText(tr("Modifier la forme d'%1").arg(name()));
|
|
}
|
|
else if (m_shapeType == Path && m_nodes != m_old_nodes)
|
|
{
|
|
// PathNode/QVector<PathNode> 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<PathNode> 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);
|
|
undo->setText(tr("Modifier la forme d'%1").arg(name()));
|
|
}
|
|
break;
|
|
|
|
case HandleRole::PathControlIn:
|
|
case HandleRole::PathControlOut:
|
|
if (m_nodes != m_old_nodes)
|
|
{
|
|
const QVector<PathNode> 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);
|
|
undo->setText(tr("Modifier la courbure d'%1").arg(name()));
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (undo)
|
|
{
|
|
// Defensive fallback only -- every role above now sets its own,
|
|
// distinct label directly (Resize/Rotate/SkewEdge/CornerRadius/
|
|
// PathAnchor/PathControlIn/PathControlOut used to all fall
|
|
// through to this same generic text, making the undo list
|
|
// unable to tell completely different edits apart); this only
|
|
// still matters if some future role is ever added without
|
|
// setting one of its own.
|
|
if (undo->text().isEmpty())
|
|
undo->setText(tr("Modifier %1").arg(name()));
|
|
|
|
// Every push here is one complete, finished gesture (press,
|
|
// drag, release) -- never a continuation of an earlier one, the
|
|
// way ArcEditor's slider deliberately pushes many commands
|
|
// during a single ongoing drag and wants them to merge.
|
|
// QPropertyUndoCommand::mergeWith() already treats any command
|
|
// with children as never mergeable; a dummy child guarantees
|
|
// that here regardless of which role produced undo. Without
|
|
// this, a single-property change (Rotate and SkewEdge always
|
|
// are; Resize and ArcEndpoint sometimes are, when only one of
|
|
// their two properties actually changed) would silently
|
|
// coalesce into whatever identically-labelled edit came right
|
|
// before it -- even after a deselect/reselect proved they were
|
|
// two separate actions, since the shared label is otherwise
|
|
// indistinguishable from a genuine continuation.
|
|
new QUndoCommand(undo);
|
|
diagram()->undoStack().push(undo);
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::fromXml
|
|
Build this item from the xml description
|
|
@param e element where is stored this item
|
|
@return true if load success
|
|
*/
|
|
bool QetShapeItem::fromXml(const QDomElement &e)
|
|
{
|
|
if (e.tagName() != "shape") return (false);
|
|
|
|
//Absent in files written before shapes carried a uuid: keep the
|
|
//one this item already has, Diagram::fromXml() settles it.
|
|
const QUuid uuid(e.attribute(QStringLiteral("uuid")));
|
|
if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid);
|
|
|
|
// 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"));
|
|
m_brush = QETXML::brushFromXml(e.firstChildElement("brush"));
|
|
|
|
QString type = e.attribute("type");
|
|
QMetaEnum me = metaObject()->enumerator(metaObject()->indexOfEnumerator("ShapeType"));
|
|
m_shapeType = QetShapeItem::ShapeType(me.keysToValue(type.toStdString().data()));
|
|
|
|
if (m_shapeType != Polygon && m_shapeType != Path)
|
|
{
|
|
m_P1.setX(e.attribute("x1", nullptr).toDouble());
|
|
m_P1.setY(e.attribute("y1", nullptr).toDouble());
|
|
m_P2.setX(e.attribute("x2", nullptr).toDouble());
|
|
m_P2.setY(e.attribute("y2", nullptr).toDouble());
|
|
|
|
if (m_shapeType == Rectangle)
|
|
{
|
|
setXRadius(e.attribute("rx", "0").toDouble());
|
|
setYRadius(e.attribute("ry", "0").toDouble());
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::toXml
|
|
Save this item to xml element
|
|
@param document parent document xml
|
|
@return element xml where is write this item
|
|
*/
|
|
QDomElement QetShapeItem::toXml(QDomDocument &document) const
|
|
{
|
|
QDomElement result = document.createElement("shape");
|
|
result.setAttribute("uuid", m_uuid.toString());
|
|
|
|
//write some attribute
|
|
QMetaEnum me = metaObject()->enumerator(metaObject()->indexOfEnumerator("ShapeType"));
|
|
result.setAttribute("type", me.valueToKey(m_shapeType));
|
|
result.appendChild(QETXML::penToXml(document, m_pen));
|
|
result.appendChild(QETXML::brushToXml(document, m_brush));
|
|
result.setAttribute("is_movable", bool(is_movable_));
|
|
result.setAttribute("closed", bool(m_closed));
|
|
|
|
if (m_shapeType != Polygon && m_shapeType != Path)
|
|
{
|
|
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)
|
|
{
|
|
result.setAttribute("rx", QString::number(m_xRadius));
|
|
result.setAttribute("ry", QString::number(m_yRadius));
|
|
}
|
|
}
|
|
if (m_shapeType == Polygon)
|
|
{
|
|
QDomElement points = document.createElement("points");
|
|
for (QPointF p : m_polygon)
|
|
{
|
|
QDomElement point = document.createElement("point");
|
|
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);
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::toDXF
|
|
Draw this element to the dxf document
|
|
@param filepath file path of the the dxf document
|
|
@param pen
|
|
@return true if draw success
|
|
*/
|
|
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)
|
|
{
|
|
case Line:
|
|
Createdxf::drawLine(filepath,
|
|
QLineF( mapToScene(m_P1),
|
|
mapToScene(m_P2)),
|
|
Createdxf::dxfColor(pen));
|
|
return true;
|
|
case Rectangle:
|
|
Createdxf::drawRectangle(filepath,
|
|
QRectF(mapToScene(m_P1),
|
|
mapToScene(m_P2)).normalized(),
|
|
Createdxf::dxfColor(pen));
|
|
return true;
|
|
case Ellipse:
|
|
Createdxf::drawEllipse(filepath,
|
|
QRectF(mapToScene(m_P1),
|
|
mapToScene(m_P2)).normalized(),
|
|
Createdxf::dxfColor(pen));
|
|
return true;
|
|
case Polygon:
|
|
// 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<QPolygonF> 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::editProperty
|
|
Edit the property of this item
|
|
*/
|
|
void QetShapeItem::editProperty()
|
|
{
|
|
if (diagram() -> isReadOnly()) return;
|
|
|
|
PropertiesEditorDialog ped(new ShapeGraphicsItemPropertiesWidget(this), diagram()->views().at(0));
|
|
ped.exec();
|
|
}
|
|
|
|
/**
|
|
@brief QetShapeItem::name
|
|
@return the name of the current shape.
|
|
*/
|
|
QString QetShapeItem::name() const
|
|
{
|
|
switch (m_shapeType) {
|
|
case Line: return tr("une ligne");
|
|
case Rectangle: return tr("un rectangle");
|
|
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");
|
|
}
|
|
}
|