1

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 }
    }
}
3
  • 3
    PathCurve smoothly connects all points by creating symmetrical quadratic curves that smoothly connect points based on the angle between iterations every three consecutive points (except for the start and end): the control points are aligned on a line that is perpendicular to that angle, which is why the first and last points result in oddly shaped curves and the middle part is oblique. In order to get the result you want, you should compose the path by using a mix of PathLine and PathQuad objects instead. Commented Jul 2 at 1:55
  • Thanks for your suggestion. I updated the post to better illustrate the problem, but your suggestion helped me to try more things. Commented Jul 2 at 15:24
  • 1
    Note that I restored your original code (along with the new one) as your latest update made the previous comment and answer out of context. Remember that the purpose of StackOverflow is not just answering people asking questions, but helping others having similar issues: when you update a question, you should also consider the context of its comments and, more importantly, already given answers (especially if they are conceptually valid), otherwise people reading the post will be confused about the references written outside your question. Commented Jul 4 at 18:54

2 Answers 2

2

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:

  • a PathLine that, from the beginning of the path, goes on the right by a certain amount;
  • a PathCubic that always targets the middle of the start and end point, with its first control point at the same Y of the start and its X further on the right of the above line, and the second control point aligned depending on the curve;
  • a PathCubic that is the opposite of the above, targeting the point where the final line would begin (at the left of the end), and with the control points symmetrically reversed to the first curve: the first cp is the "mirrored" version of the second of the previous curve (with the center as reference), while the second is horizontally positioned opposite to the target and vertically aligned to the end;
  • a PathLine that implicitly starts at the left of the end of the path (where the second PathCubic ends) and ends with the final point, as opposed to the first line;

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!

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you very much for your very detailed response @musicamante ! Thanks to your explanation and code example I managed to understand the issue and get the exact results that I was looking for. The trick is to understand how to place the control points correctly. :) This is how mine looks: i.gyazo.com/135b8a1f424c3c1130f0df739d81298c.gif
1

Simular to @musicamante advice, you can get better control by mixing in PathLine. I also found that relative coordinates may be easier to read and maintain than absolute coordinates:

ShapePath {
    strokeWidth: 3
    strokeColor: "darkgray"
    fillColor: "transparent"
            
    startX: portA.x + portA.width/2
    startY: portA.y + portA.height/2
            
    PathLine   { relativeX: 45; relativeY: 0 }
    PathCurve  { relativeX: 15; relativeY: 15 }
    PathCurve  { relativeX: 0;  relativeY: (portB.y - portA.y) / 2 - 30 }
    PathCurve  { relativeX: -15; relativeY: 15 }
    PathLine   { relativeX: (portB.x - portA.x) - 90; relativeY: 0 }
    PathCurve  { relativeX: -15; relativeY: 15 }
    PathCurve  { relativeX: 0; relativeY: (portB.y - portA.y) / 2 - 30 }
    PathCurve  { relativeX: 15; relativeY: 15 }
    PathLine   { relativeX: 45; relativeY: 0 }
}

You can Try it Online!

2 Comments

Thanks for the link! I didn't know about it. I just updated the question to better explain the problem, though. I'm not looking for that specific shape, that was just to illustrate one of its possible deformations. I believe this gif should explain better what I'm trying to achieve: i.gyazo.com/b901f4b4e844b9ac8aaa1c1edc9687fa.gif
@StephenQuan I don't think that PathCurve is appropriate for this, as it's difficult to ensure that the orientation of the start and end of the curve is horizontal no matter the origin points. I took the liberty of using your "qmlonline" repo for my ownanswer, I hope it's not an issue. Also, I fundamentally have little to no experience with QML, so I'm not 100% sure that my approach/syntax/semantics can be considered valid in that context: if you have time to review it, I'll be happy to get any suggestions from you.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.