qtqmlqtquick2

QtQuick curve with rounded corners


I'm trying to create a spline in QTQuick using a ShapePath component, however I'm a bit lost and don't know where to start. The following gif and images are a reference for the result that I'm looking for, that type of deformation: https://i.gyazo.com/b901f4b4e844b9ac8aaa1c1edc9687fa.gif

enter image description here

enter image description here

enter image description here

I think it is composed of different components: A line -> a curve with control points that may change dynamically to adjust the curve as you move the nodes -> another line at the end.

This image is what I got so far hard coding values: enter image description here

I used 3 PathLine elements to illustrate this. I believe the middle line should be a path with control points that change dynamically somehow, to get it to deform like in the provided examples.

My code so far:

import QtQuick
import QtQuick.Shapes

Shape
{
    id: root
    anchors.fill: parent
    containsMode: Shape.BoundingRectContains

    // Dynamic port positions
    property point startPort: Qt.point(0, 0)
    property point endPort: Qt.point(0, 0)
    property int offset: 20

    ShapePath
    {
        strokeWidth: 4
        strokeColor: "#ff9900"
        fillColor: "transparent"

        startX: startPort.x
        startY: startPort.y

        PathLine
        {
            id: firstL
            relativeX: root.offset; relativeY: 0
        }

        PathLine
        {
            x: endPort.x - root.offset; y: endPort.y
        }

        PathLine
        {
            relativeX: root.offset; relativeY: 0
        }
    }

}

I updated the question to better illustrate the problem. Any pointer in the right direction is appreciated.

Original attempt:

import QtQuick
import QtQuick.Shapes

Shape
{
    Rectangle
    {
        id: portA; width: 14; height: 14; radius: 7; color: "#29b6f6"
        x: 500; y: 200
    }
    Rectangle
    {
        id: portB; width: 14; height: 14; radius: 7; color: "#29b6f6"
        x: 220; y: 300
    }
    ShapePath {
        strokeWidth: 3
        strokeColor: "darkgray"
        fillColor: "transparent"
        
        startX: portA.x + portA.width/2
        startY: portA.y + portA.height/2
        
        PathCurve { x: portA.x + portA.width/2 + 50; y: portA.y + portA.height/2}
        PathCurve { x:  portA.x + portA.width/2; y:  portA.y + portA.height/2 + 50 }
        
        PathCurve { x: portB.x + portB.width/2; y: portB.y + portB.width/2 - 50}
        
        PathCurve { x: portB.x + portA.width/2 - 50; y: portB.y + portA.height/2}
        PathCurve { x: portB.x + portB.width/2; y: portB.y + portB.width/2 }
    }
}

Solution

  • The problem with using PathCurve is that it constructs a curve that always passes through all points, by using "symmetrical" Bézier curves.
    Interestingly enough, your original question mentioned trying to get a Catmull-Rom spline, which is a possible case of Cubic Hermite spline, which, in turn, like most cubic splines, is often considered as a conceptual synonim of Bézier cubic curves.

    Simply putting four points in a "Z" shape won't be appropriate, though, especially if you want a specific orientation for the beginning and ending of the path (horizontal, in this case).

    Consider the following example:

    Attempt for Z shaped points

    Even though the first and last two points are placed at the same y coordinate, the curve begins and ends by going a bit above or below that vertical position. Such a curve is conceptually optimal (it often has the shorter or more effective "smooth" path between a set of given points) but it's not usually preferable for UI purposes.

    It would be tempting to put the second point a bit lower and the third a bit higher, but it won't improve the situation. Here is an example that also shows the control points used to make the curve to "smoothly" pass through all given points:

    Second attempt

    The control points of each curve are placed perpendicularly to the angle formed between the previous and following point, and considering the distance between them. The following image shows the correlation:

    Correlation between original points and Bézier control points

    Even trying to add more points won't solve the problem:

    More points, still failing

    And here is the same path showing how the control points are placed:

    Same example, showing control points

    Even though it would be possible to compute the position of the intermediate points so that the curve immediately goes on the "right" direction, such computations are all but immediate, as they require considering the involved angles and lengths between each segment and then doing a "reverse" computation to get back the coordinates from the possible control points.

    A more appropriate solution would be to consider the possibility of using PathQuad (which results in a quadratic Bézier, having a single control point) or PathCubic (for a cubic Bézier, with two control points), possibly considering the possibility of adding PathLine elements for straight lines before, between and after curves.

    In reality, most of the times using two cubic curves is simpler and gets the best results, the only problem is to properly compute the position of the control points.

    For a "node-connection" system, it's normal to have a curve going on the right of the starting point and ending on the left of the target, so we can actually make a path made of two PathLines (for the extremities) and two PathCubic:

    Proper path with distinct cubic curves

    The first curve starts from the second point (after the first PathLine), and goes exactly in the middle of the original two points, while the second curve does the opposite. The control points for each curve are vertically aligned to the y coordinate of the start/end points and the middle, and are horizontally placed to some "extent" going outside the margin.

    It's important to realize that it's usually not enough to just use the relative positions from the origin (start/end) points, because if the start is on the left of the end, the aligned control points will create an oddly shaped curve:

    Oddly shaped path

    Therefore, as soon as the first control point of the first curve and the second of the last are horizontally far apart, the orientation of the other control points must be swapped:

    More appropriate path

    Note that, in reality, a curve similar to the above could be drawn with a single cubic Bézier:

    Similar path with a single cubic curve

    But since we have a path with predefined element types, we need to make an interpolation of the two existing curves, even if we don't really need them; this has the benefit of having more control over the shape of each curve.

    The structure is then the following:

    Remember that ShapePath inherits from Path, which behaves like QPainterPath: every new "relative draw" element (lines, curves) always implies that it begins at the previous point in the path (with 0, 0 implied as its start), with the exception of "polygons" (rectangles, non-regular polygons and ellipses); therefore a PathLine only specifies its "end point" and always assumes its start as the previous point in the path, just like elements such as PathQuad, PathCubic, PathCurve, etc. do.

    Declaring all these values within each element is possible, but a lot of computations and checks would be unnecessarily done more than once, leading to a lot of boilerplate code that would affect performance. Using a function is much simpler, because the above computations/checks can be done preliminarly and be kept as local variables or provide immediate assignment to avoid checking them again and again; that approach also allows to make some fine-tuning to the curve variables which would be otherwise more difficult and riskier to maintain.

    In the following example, the start and end points have been made movable by adding a MouseArea, and the updatePath() function is called every time one of them is moved (other than on start up).

    The extend variable indicates the extent of the horizontal lines on the right of the first element and the left of the last, while the minCurve is used as reference for the control point position. You can obviously play around with those values to see the differences.

    import QtQuick
    import QtQuick.Controls
    import QtQuick.Shapes
    Page {
        property int extend: 45
        property int minCurve: 15
        function updatePath() {
            // coordinates of the "center" between the start and end points
            var midX = portPath.startX + (portB.x - portA.x) / 2
            var midY = portPath.startY + (portB.y - portA.y) / 2
    
            // the theoretical horizontal position of the control points used as
            // reference: the 1st cp of the start curve, and the 2nd of the end
            var startCp = portPath.startX + extend + minCurve
            var endCp = endLine.x - extend - minCurve
    
            startCurve.x = midX
            startCurve.y = midY
    
            if (startCp < endCp) {
                // the theoretical startCp is on the *left* of endCp, so we
                // should have a path similar to the last example above;
                // the other control points are placed at the same Y of the
                // start or end points; the "dist" variable is considered in case
                // the two control points are far apart enough, providing a more
                // "smooth" slope in the initial and final parts of the curves
    
                var dist = (endLine.x - portPath.startX) / 2 - extend
                if (dist < minCurve) { dist = minCurve }
    
                startCurve.control1X = midX - dist
                startCurve.control2X = midX
                endCurve.control1X = midX
                endCurve.control2X = midX + dist
                startCurve.control2Y = portPath.startY
                endCurve.control1Y = endCurve.control2Y
    
            } else {
                // the opposite, where the 1st cp of the start curve is on the
                // *right* of the 2nd cp of the end curve; the "diff" variable
                // is conceptually similar to the "dist" above
    
                var diff = (startCp - endCp) / 25
    
                startCurve.control1X = startCp + diff
                startCurve.control2X = startCurve.control1X
                endCurve.control1X = endCurve.control2X = endCp - diff
                startCurve.control2Y = endCurve.control1Y = midY
            }
        }
    
        Shape
        {
            Rectangle
            {
                id: portA;
                x: 500; y: 200
                width: 14; height: 14;
                radius: 7;
                color: "#29b6f6"
                MouseArea {
                    anchors.fill: parent
                    drag.target: parent
                    drag.threshold: 0
                    onPositionChanged: updatePath()
                }
            }
            Rectangle
            {
                id: portB;
                x: 220; y: 300
                width: 14; height: 14;
                radius: 7;
                color: "#29b6f6"
                MouseArea {
                    anchors.fill: parent
                    drag.target: parent
                    drag.threshold: 0
                    onPositionChanged: updatePath()
                }
            }
            ShapePath {
                id: portPath
                strokeWidth: 3
                strokeColor: "darkgray"
                fillColor: "transparent"
                
                startX: portA.x + portA.width / 2
                startY: portA.y + portA.height / 2
    
               
                PathLine   {
                    id: startLine
                    x: portPath.startX + extend
                    y: portPath.startY
                }
                PathCubic  {
                    id: startCurve
                    control1Y: portPath.startY
                }
                PathCubic  {
                    id: endCurve
                    x: portB.x + portB.width / 2 - extend
                    y: portB.y + portB.height / 2
                    control2Y: y
                }
                PathLine   {
                    id: endLine
                    x: portB.x + portB.width / 2
                    y: endCurve.y
                }
            }
        }
        Component.onCompleted: updatePath()
    }
    

    Here is how the above will show on start up:

    Screenshot of the code above

    And when swapping the horizontal position:

    Inverted horizontal positions

    Thanks to Stephen Quan's repository, you can also Try it Online!