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 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-01 22:49:35 +13:00
parent 7fdc314e41
commit ffa76a61c1
5 changed files with 128 additions and 3 deletions
+33 -2
View File
@@ -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:
+2 -1
View File
@@ -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
+6
View File
@@ -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")
+82
View File
@@ -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<QPointF> 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)
{
+5
View File
@@ -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);