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
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:
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 }
}
}
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:
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:
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:
Even trying to add more points won't solve the problem:
And here is the same path showing how the control points are placed:
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:
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:
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:
Note that, in reality, a curve similar to the above could be drawn with a single cubic Bézier:
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:
And when swapping the horizontal position:
Thanks to Stephen Quan's repository, you can also Try it Online!