2

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?

Gyum Fox
  • 3,287
  • 2
  • 41
  • 71
  • Excluding the ultimate start and end points, you would normally expect 3n-1 points for a complete list of control points. I can imagine two ways to interpret receiving four points: a) in addition to the list of bend points, there is a second list of points the path goes through (or what was a list with entries for start and end point has now three entries), or b) some sort of symmetry operation is implied. See for example the SVG [`S` command](https://www.w3.org/TR/SVG2/paths.html#PathDataCubicBezierCommands) for a common way to write this - although with your list, it would look strange. – ccprog Jun 10 '22 at 16:39
  • 1
    Based on where https://www.eclipse.org/elk/support.html says to go for support: did you already ask them how the point list should be interpreted? If not, that should have been step 1, and it's not too late to do that still (which may then even allow you to answer your own question if you think others will benefit from that in the future). – Mike 'Pomax' Kamermans Jun 10 '22 at 18:53
  • Do you know what the last shape is supposed to look like? – Sphinxxx Jun 11 '22 at 13:05
  • Unfortunately I do not know what the resulting shape is supposed to be. I've reached to the gitter chat from their support page, but no answer yet. I thought it was just about my lack of knowledge, but if nobody can actually understand clearly what Elk's documentation means, I'll consider raisin an issue. Thanks for the help anyway – Gyum Fox Jun 11 '22 at 17:38
  • 2
    With a bit of delay (I've been experimenting with various solutions), here is the ticket on github: https://github.com/eclipse/elk/issues/848 – Gyum Fox Jun 15 '22 at 10:24

2 Answers2

2

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)
Mike 'Pomax' Kamermans
  • 49,297
  • 16
  • 112
  • 153
  • I find that highly speculative. Why should that interpolated mid-point be the mid between the neighbouring control points, and not the mid between the start and end points, or any other pair you could reasonably think of? – ccprog Jun 10 '22 at 18:19
  • Because that's how other technologies do this too (like truetype outlines). As for being speculative: that's literally what the answer starts with, and the OP is free to provide more details to make things less speculative. Based on https://www.eclipse.org/elk/reference/options/org-eclipse-elk-edgeRouting.html however, and familiarity with other Bezier related technologies, this is entirely reasonable speculation. – Mike 'Pomax' Kamermans Jun 10 '22 at 18:43
1

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)
Gyum Fox
  • 3,287
  • 2
  • 41
  • 71