How to draw a piecewise cubic spline in SVG

1.2k Views Asked by At

I receive a list of points from my layout engine (ELK) which I would like to convert to a SVG path.

I've got the following:

When I receive exactly two bend points, I am able to convert this to a cubic Bezier curve with two control points in SVG:

<svg width="400" height="100">
  <g stroke="black" fill="black">
    <!--Start point-->
    <circle cx="10" cy="10" r="2" />
    <!--Bend points-->
    <circle cx="90" cy="60" r="1" />
    <circle cx="210" cy="60" r="1" />
    <!--End point-->
    <circle cx="290" cy="10" r="2" />  
  </g>
  <!--Resulting path-->
  <path d="M 10 10 C 90 60, 210 60, 290 10" stroke="blue" fill="none" />
</svg>

But when I receive more than 2 control points, I struggle to understand what should be the resulting path. Eg with 4 control points:

<svg width="400" height="100">
  <g stroke="black" fill="black">
    <!--Start point-->
    <circle cx="10" cy="10" r="2" />
    <!--Bend points-->
    <circle cx="50" cy="60" r="1" />
    <circle cx="90" cy="60" r="1" />
    <circle cx="210" cy="60" r="1" />
    <circle cx="250" cy="60" r="1" />
    <!--End point-->
    <circle cx="290" cy="10" r="2" />  
  </g>
  <!--Resulting path?-->
</svg>

So how can I convert a "piecewise cubic spline" with a variable amount control points to a SVG path?

2

There are 2 best solutions below

2
On

Based on the text it sounds like you're dealing with a fairly simple "each omitted point lies exactly between the control points", which means your points should be interpreted as:

on-curve: 10,10
control1: 50, 60
control2: 90, 60
on-curve: MID-POINT OF PREVIOUS AND NEXT CONTROL POINTS
control1: 210,60
control2: 250,60
on-curve: 290, 10

Which means that each missing on-curve point is trivially computed using (previous control 2 + following control 1)/2, so in this case the missing point is (90 + 210) /2, (60 + 60) / 2 = 150, 60.

<svg width="400" height="100">
  <g stroke="black" fill="black">
    <!--Start point-->
    <circle cx="10" cy="10" r="2" />
    <!--control points-->
    <circle cx="50" cy="60" r="1" />
    <circle cx="90" cy="60" r="1" />
    <!-- implicit point -->
    <circle cx="150" cy="60" r="2" />
    <!--control points-->
    <circle cx="210" cy="60" r="1" />
    <circle cx="250" cy="60" r="1" />
    <!--End point-->
    <circle cx="290" cy="10" r="2" />  
  </g>
  <path stroke="blue" fill="none" 
        d="M 10 10
           C 50 60, 90 60, 150 60
             210 60, 250 60, 290 10"/>
</svg>

And of course in general, in pseudo-code:

# First, remove the start point from the list
start <- points.shift

# Then build the missing points, which requires running
# through the point list in reverse, so that data
# at each iteration is unaffected by previous insertions.

i <- points.length - 3
while i >= 2:
  points.insert(i, (points[i-1] + points[i])/2 )
  i <- i - 2

# Now we can walk through the completed point set.
moveTo(start)
for each (c1,c2,p) in points:
  cubicCurveTo(c1, c2, p)
0
On

I never got a clear answer to my question from the ELK team, although they pointed me to the code they use in their vscode extension and in their Java application. So based on that, and this anwser, I ended up using this code (JavaScript). I can't say it's correct, but I managed to draw decent splines no matter the number of points received:

function getBezierPathFromPoints(points) {
  const [start, ...controlPoints] = points;

  const path = [`M ${ptToStr(start)}`];

  // if only one point, draw a straight line
  if (controlPoints.length === 1) {
    path.push(`L ${ptToStr(controlPoints[0])}`);
  }
  // if there are groups of 3 points, draw cubic bezier curves
  else if (controlPoints.length % 3 === 0) {
    for (let i = 0; i < controlPoints.length; i = i + 3) {
      const [c1, c2, p] = controlPoints.slice(i, i + 3);
      path.push(`C ${ptToStr(c1)}, ${ptToStr(c2)}, ${ptToStr(p)}`);
    }
  }
  // if there's an even number of points, draw quadratic curves
  else if (controlPoints.length % 2 === 0) {
    for (let i = 0; i < controlPoints.length; i = i + 2) {
      const [c, p] = controlPoints.slice(i, i + 2);
      path.push(`Q ${ptToStr(c)}, ${ptToStr(p)}`);
    }
  }
  // else, add missing points and try again
  // https://stackoverflow.com/a/72577667/1010492
  else {
    for (let i = controlPoints.length - 3; i >= 2; i = i - 2) {
      const missingPoint = midPoint(controlPoints[i - 1], controlPoints[i]);
      controlPoints.splice(i, 0, missingPoint);
    }
    return getBezierPathFromPoints([start, ...controlPoints]);
  }

  return path.join(' ');
}

function midPoint(pt1, pt2) {
  return {
    x: (pt2.x + pt1.x) / 2,
    y: (pt2.y + pt1.y) / 2,
  };
}

function ptToStr({ x, y }) {
  return `${x} ${y}`;
}

Explanation: we set the start point and take the remaining points. Then:

  • If there's only one point left, we draw a straight line
  • If we have a multiple of 3 points, then draw Bezier curves
  • If we have an even number of points, we draw quadratic curves
  • Else, we add midpoints as described in this answer, then we try again (recurse)