From ffa76a61c1f6bf8f0b97a959ac2adc136c1f6572 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Thu, 1 Oct 2026 22:49:35 +1300 Subject: [PATCH] Add an Arc tool and a handle to pull a half arc in or out (#408) The diagram toolbar gets an "Ajouter un arc" button next to the ellipse. Click one end, then click the other end at the height the arc should reach: the result is a half arc bulging up or down from the line between the two clicks (Shift: a true half circle). It is not filled, since a fill would close it into a half disc. A selected half arc shows one more handle, in the middle of the curve. Dragging it makes the arc deeper or flatter while both ends stay put; dragging it across the line between the ends turns the arc over. The handle is hidden whenever the arc is not a half arc on a horizontal or vertical diameter, because only then is "keep the ends, move the middle" one well-defined change. Nothing new is stored: an arc stays an Ellipse with a start and end angle, exactly as the existing arc handles of an ellipse save it, so files read by older versions are unchanged. Co-Authored-By: Claude Opus 5.5 --- sources/diagramevent/diagrameventaddshape.cpp | 35 +++++++- sources/diagramevent/diagrameventaddshape.h | 3 +- sources/qetdiagrameditor.cpp | 6 ++ sources/qetgraphicsitem/qetshapeitem.cpp | 82 +++++++++++++++++++ sources/qetgraphicsitem/qetshapeitem.h | 5 ++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/sources/diagramevent/diagrameventaddshape.cpp b/sources/diagramevent/diagrameventaddshape.cpp index 26072980a..c1722a355 100644 --- a/sources/diagramevent/diagrameventaddshape.cpp +++ b/sources/diagramevent/diagrameventaddshape.cpp @@ -34,10 +34,14 @@ Default constructor @param diagram : the diagram where this event must operate @param shape_type : the type of shape to draw + @param half_arc : with shape_type Ellipse, draw a half arc instead of + a whole ellipse (the Arc tool). It is still an Ellipse with a start + and end angle, the same shape the arc handles of an ellipse produce. */ -DiagramEventAddShape::DiagramEventAddShape(Diagram *diagram, QetShapeItem::ShapeType shape_type) : +DiagramEventAddShape::DiagramEventAddShape(Diagram *diagram, QetShapeItem::ShapeType shape_type, bool half_arc) : DiagramEventInterface(diagram), m_shape_type (shape_type), + m_half_arc (half_arc && shape_type == QetShapeItem::Ellipse), m_shape_item (nullptr), m_help_horiz (nullptr), m_help_verti (nullptr) @@ -131,6 +135,23 @@ void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifie return; } + if (m_half_arc) + { + // The first click is one end of the arc and sets the height of + // its chord; the cursor sets the other end and how far the arc + // bulges, up or down, from that chord. Shift makes it a true + // half circle. + const qreal width = qAbs(pos.x() - m_anchor_point.x()); + qreal height = pos.y() - m_anchor_point.y(); + if (mods & Qt::ShiftModifier) + height = (height < 0 ? -width : width) / 2; + const qreal depth = qAbs(height); + m_shape_item->setRect(QRectF(qMin(pos.x(), m_anchor_point.x()), m_anchor_point.y() - depth, + width, 2 * depth)); + m_shape_item->setEndAngle(height <= 0 ? 180 : -180); + return; + } + m_center_anchored = (mods & Qt::ControlModifier) && (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse); if (m_center_anchored) @@ -227,15 +248,20 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event) // live on every subsequent move, from whatever Ctrl state is // held at the time. m_center_anchored = (event->modifiers() & Qt::ControlModifier) + && !m_half_arc && (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse); if (m_center_anchored) showCenterMarker(m_anchor_point); + if (m_half_arc) + m_shape_item->setEndAngle(180); //Start from whatever pen/brush was last applied this //session, rather than always the hardcoded default. + //Not the brush for an arc: it is a line, and a fill + //would close it into a half disc. if (LastUsedStyle::hasShapePen()) { m_shape_item->setPen(LastUsedStyle::shapePen()); } - if (LastUsedStyle::hasShapeBrush()) { + if (LastUsedStyle::hasShapeBrush() && !m_half_arc) { m_shape_item->setBrush(LastUsedStyle::shapeBrush()); } m_diagram->addItem (m_shape_item); @@ -461,6 +487,8 @@ QString DiagramEventAddShape::beforeClickHint() const return tr("Clic gauche : positionner le point de départ (Ctrl = position libre)"); case QetShapeItem::Rectangle: case QetShapeItem::Ellipse: + if (m_half_arc) + return tr("Clic gauche : positionner une extrémité de l'arc (Ctrl = position libre)"); return tr("Clic gauche : positionner le premier coin (Ctrl = point central, position libre)"); case QetShapeItem::Polygon: return tr("Clic gauche : positionner le premier point (Ctrl = position libre)"); @@ -479,6 +507,9 @@ QString DiagramEventAddShape::afterClickHint() const return tr("Clic gauche : positionner le coin opposé (Maj = carré, " "Ctrl = depuis le centre + position libre, Ctrl+Maj = carré centré) ; clic droit : annuler"); case QetShapeItem::Ellipse: + if (m_half_arc) + return tr("Clic gauche : positionner l'autre extrémité et la hauteur de l'arc " + "(Maj = demi-cercle, Ctrl = position libre) ; clic droit : annuler"); return tr("Clic gauche : positionner le coin opposé (Maj = cercle, " "Ctrl = depuis le centre + position libre, Ctrl+Maj = cercle centré) ; clic droit : annuler"); case QetShapeItem::Polygon: diff --git a/sources/diagramevent/diagrameventaddshape.h b/sources/diagramevent/diagrameventaddshape.h index 9f46b4408..597917d86 100644 --- a/sources/diagramevent/diagrameventaddshape.h +++ b/sources/diagramevent/diagrameventaddshape.h @@ -32,7 +32,7 @@ class DiagramEventAddShape : public DiagramEventInterface Q_OBJECT public: - DiagramEventAddShape(Diagram *diagram, QetShapeItem::ShapeType shape_type); + DiagramEventAddShape(Diagram *diagram, QetShapeItem::ShapeType shape_type, bool half_arc = false); ~DiagramEventAddShape() override; void mousePressEvent (QGraphicsSceneMouseEvent *event) override; @@ -55,6 +55,7 @@ class DiagramEventAddShape : public DiagramEventInterface protected: QetShapeItem::ShapeType m_shape_type; + bool m_half_arc = false; // Ellipse only: draw a half arc whose chord is the first click's height (the Arc tool) QetShapeItem *m_shape_item; QGraphicsLineItem *m_help_horiz, *m_help_verti; QPointF m_anchor_point; // the shape's first-click point -- meaningful once m_shape_item exists diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 2282dd859..7cbfb4ed2 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1048,6 +1048,7 @@ void QETDiagramEditor::setUpActions() QAction *add_line = m_add_item_actions_group.addAction(QET::Icons::PartLine, tr("Ajouter une ligne", "Draw line")); QAction *add_rectangle = m_add_item_actions_group.addAction(QET::Icons::PartRectangle, tr("Ajouter un rectangle")); QAction *add_ellipse = m_add_item_actions_group.addAction(QET::Icons::PartEllipse, tr("Ajouter une ellipse")); + QAction *add_arc = m_add_item_actions_group.addAction(QET::Icons::PartArc, tr("Ajouter un arc")); QAction *add_polyline = m_add_item_actions_group.addAction(QET::Icons::PartPolygon, tr("Ajouter une polyligne")); QAction *add_path = m_add_item_actions_group.addAction(QET::Icons::PartBezier, tr("Ajouter une courbe")); QAction *add_terminal_strip = m_add_item_actions_group.addAction(QET::Icons::TerminalStrip, tr("Ajouter un plan de bornes")); @@ -1060,6 +1061,7 @@ void QETDiagramEditor::setUpActions() add_line ->setStatusTip(tr("Ajoute une ligne sur le folio actuel")); add_rectangle->setStatusTip(tr("Ajoute un rectangle sur le folio actuel")); add_ellipse ->setStatusTip(tr("Ajoute une ellipse sur le folio actuel")); + add_arc ->setStatusTip(tr("Ajoute un arc sur le folio actuel")); add_polyline ->setStatusTip(tr("Ajoute une polyligne sur le folio actuel")); add_path ->setStatusTip(tr("Ajoute une courbe de Bézier sur le folio actuel")); add_terminal_strip->setStatusTip(tr("Ajoute un plan de bornier sur le folio actuel")); @@ -1072,6 +1074,7 @@ void QETDiagramEditor::setUpActions() add_line ->setData(QStringLiteral("line")); add_rectangle->setData(QStringLiteral("rectangle")); add_ellipse ->setData(QStringLiteral("ellipse")); + add_arc ->setData(QStringLiteral("arc")); add_polyline ->setData(QStringLiteral("polyline")); add_path ->setData(QStringLiteral("path")); add_terminal_strip->setData(QStringLiteral("terminal_strip")); @@ -1080,6 +1083,7 @@ void QETDiagramEditor::setUpActions() add_line->setCheckable(true); add_rectangle->setCheckable(true); add_ellipse->setCheckable(true); + add_arc->setCheckable(true); add_polyline->setCheckable(true); add_path->setCheckable(true); @@ -2024,6 +2028,8 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) diagram_event = new DiagramEventAddShape (d, QetShapeItem::Rectangle); else if (value == "ellipse") diagram_event = new DiagramEventAddShape (d, QetShapeItem::Ellipse); + else if (value == "arc") + diagram_event = new DiagramEventAddShape (d, QetShapeItem::Ellipse, true); else if (value == "polyline") diagram_event = new DiagramEventAddShape (d, QetShapeItem::Polygon); else if (value == "path") diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp index a6d0202a9..b2f3a152d 100644 --- a/sources/qetgraphicsitem/qetshapeitem.cpp +++ b/sources/qetgraphicsitem/qetshapeitem.cpp @@ -398,6 +398,21 @@ void QetShapeItem::setEndAngle(qreal degrees) emit arcChanged(); } +/** + @brief QetShapeItem::isAxisHalfArc + True for an Ellipse drawn as a half arc whose two ends lie on a + horizontal or vertical diameter -- what the Arc tool draws. Only + then does the bulge handle have one obvious meaning: move the middle + of the arc toward or away from the line between its ends, with both + ends staying put. +*/ +bool QetShapeItem::isAxisHalfArc() const +{ + if (m_shapeType != Ellipse || !qFuzzyCompare(qAbs(spanAngle()), qreal(180))) + return false; + return qFuzzyIsNull(std::remainder(m_startAngle, 90.0)); +} + void QetShapeItem::setArcClosure(ArcClosure closure) { if (m_arcClosure == closure) return; @@ -1435,6 +1450,8 @@ QString QetShapeItem::handleRoleTooltip(HandleRole role, int slot) const 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::ArcBulge: + return tr("Glisser : creuser ou aplatir l'arc, ses extrémités restent en place (Ctrl = position libre)"); case HandleRole::PathAnchor: { QString text = tr("Glisser : déplacer le point (Ctrl = position libre"); @@ -1469,6 +1486,7 @@ QColor QetShapeItem::colorForHandleRole(HandleRole role) case HandleRole::Pivot: return Qt::red; case HandleRole::CornerRadius: return Qt::magenta; case HandleRole::ArcEndpoint: return Qt::darkCyan; + case HandleRole::ArcBulge: return Qt::darkCyan; case HandleRole::PathAnchor: return Qt::blue; case HandleRole::PathControlIn: case HandleRole::PathControlOut:return Qt::gray; @@ -1543,6 +1561,9 @@ QPointF QetShapeItem::handlePositionFor(HandleRole role, int slot) const case HandleRole::ArcEndpoint: return QetGraphicsHandlerUtility::pointsForArc(r, m_startAngle, spanAngle()).value(slot); + case HandleRole::ArcBulge: + return QetGraphicsHandlerUtility::pointsForArc(r, m_startAngle + spanAngle() / 2, 0).value(0); + case HandleRole::PathAnchor: if (m_shapeType == Polygon) return m_polygon.value(slot); @@ -1653,7 +1674,14 @@ void QetShapeItem::rebuildHandles() addRole(HandleRole::Pivot, 0); } if (m_shapeType == Ellipse) + { for (int i = 0; i < 2; ++i) addRole(HandleRole::ArcEndpoint, i); + // Always in the set, hidden unless the shape is a half + // arc (updateArcBulgeVisibility()): an endpoint drag can + // make or unmake a half arc mid-drag, and changing the + // handle set then would delete the handle being dragged. + addRole(HandleRole::ArcBulge, 0); + } break; case Polygon: @@ -1715,6 +1743,14 @@ void QetShapeItem::rebuildHandles() scene()->addItem(h); h->installSceneEventFilter(this); } + updateArcBulgeVisibility(); +} + +void QetShapeItem::updateArcBulgeVisibility() +{ + const int index = m_handleRoles.indexOf(HandleRole::ArcBulge); + if (index >= 0 && index < m_handler_vector.size()) + m_handler_vector.at(index)->setVisible(isAxisHalfArc()); } /** @@ -1741,6 +1777,7 @@ void QetShapeItem::repositionHandles() const QVector scenePositions = mapToScene(positions); for (int i = 0; i < scenePositions.size(); ++i) m_handler_vector.at(i)->setPos(scenePositions.at(i)); + updateArcBulgeVisibility(); } void QetShapeItem::insertPoint() @@ -2527,6 +2564,40 @@ void QetShapeItem::dragArcEndpoint(int which, const QPointF &localPos, Qt::Keybo which == 0 ? setStartAngle(angle) : setEndAngle(angle); } +/** + @brief QetShapeItem::dragArcBulge + Pulls the middle of a half arc in or out while its two ends stay + where they are: only the half-axis across the line between the ends + changes. Dragging across that line turns the arc over to the other + side, which flips the sign of the span by changing the end angle + alone -- changing both angles one after the other would pass through + a zero span, which setStartAngle()/setEndAngle() snap to a full + ellipse. +*/ +void QetShapeItem::dragArcBulge(const QPointF &localPos) +{ + if (!isAxisHalfArc()) + return; + + const QRectF r = localRect(); + const QPointF c = r.center(); + const bool horizontalChord = qFuzzyIsNull(std::remainder(m_startAngle, 180.0)); + // > 0: the cursor is above (horizontal chord) or left of + // (vertical chord) the line between the two ends + const qreal side = horizontalChord ? c.y() - localPos.y() : c.x() - localPos.x(); + const qreal depth = qMax(qAbs(side), qreal(1)); + + if (horizontalChord) + setRect(QRectF(r.left(), c.y() - depth, r.width(), 2 * depth)); + else + setRect(QRectF(c.x() - depth, r.top(), 2 * depth, r.height())); + + const QPointF middle = handlePositionFor(HandleRole::ArcBulge, 0); + const qreal middleSide = horizontalChord ? c.y() - middle.y() : c.x() - middle.x(); + if ((middleSide > 0) != (side > 0)) + setEndAngle(2 * m_startAngle - m_endAngle); +} + void QetShapeItem::dragCornerRadius(int which, const QPointF &localPos) { const qreal radius = QetGraphicsHandlerUtility::radiusForPosAtIndex(localRect(), localPos, which); @@ -2755,6 +2826,7 @@ void QetShapeItem::handlerMouseMoveEvent(int handlerIndex, QGraphicsSceneMouseEv 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::ArcBulge: dragArcBulge(new_pos); 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; @@ -2852,6 +2924,16 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex) undo->setText(tr("Modifier l'angle d'un arc")); break; + case HandleRole::ArcBulge: + if (m_P1 != m_old_P1 || m_P2 != m_old_P2 || !qFuzzyCompare(m_endAngle, m_old_endAngle)) + { + undo = new QUndoCommand(tr("Modifier la courbure d'un arc")); + new QPropertyUndoCommand(this, "rect", QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized(), undo); + if (!qFuzzyCompare(m_endAngle, m_old_endAngle)) + new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle, undo); + } + break; + case HandleRole::PathAnchor: if (m_shapeType == Polygon && m_polygon != m_old_polygon) { diff --git a/sources/qetgraphicsitem/qetshapeitem.h b/sources/qetgraphicsitem/qetshapeitem.h index 610f06bf5..ba4c2ae87 100644 --- a/sources/qetgraphicsitem/qetshapeitem.h +++ b/sources/qetgraphicsitem/qetshapeitem.h @@ -125,11 +125,13 @@ class QetShapeItem : public QetGraphicsItem // SkewEdge 0..3, edges: N, E, S, W // CornerRadius 0..1, same order as QetGraphicsHandlerUtility::pointForRadiusRect // ArcEndpoint 0 = start angle, 1 = end angle + // ArcBulge 0 only: the middle of a half arc enum class HandleRole { Resize, // Size mode Rotate, SkewEdge, Pivot, // RotateSkew mode CornerRadius, // Rectangle, always shown alongside Size handles ArcEndpoint, // Ellipse, always shown + ArcBulge, // Ellipse, shown only on a half arc (see isAxisHalfArc()) PathAnchor, PathControlIn, PathControlOut // Polygon/Path, node-edit mode (see setPathNodes()) }; @@ -205,6 +207,7 @@ class QetShapeItem : public QetGraphicsItem void setEndAngle(qreal degrees); qreal spanAngle() const {return m_endAngle - m_startAngle;} bool isFullEllipse() const {return qFuzzyCompare(qAbs(spanAngle()), qreal(360));} + bool isAxisHalfArc() const; // half arc whose two ends lie on a horizontal or vertical diameter ArcClosure arcClosure() const {return m_arcClosure;} void setArcClosure(ArcClosure closure); @@ -278,6 +281,8 @@ class QetShapeItem : public QetGraphicsItem void dragSkewHandle (int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods); void dragPivotHandle (const QPointF &localPos); void dragArcEndpoint (int which, const QPointF &localPos, Qt::KeyboardModifiers mods); + void dragArcBulge (const QPointF &localPos); + void updateArcBulgeVisibility(); void dragCornerRadius(int which, const QPointF &localPos); void dragPathAnchor (int which, const QPointF &localPos, Qt::KeyboardModifiers mods); void dragPathControlHandle(bool isOutHandle, int nodeIndex, const QPointF &localPos, Qt::KeyboardModifiers mods);