eclipse / elk

Eclipse Layout Kernel - Automatic layout for Java applications.
https://www.eclipse.org/elk/
Other
257 stars 83 forks source link

How to interpret the bend points used to route edges with splines? #848

Closed gfox1984 closed 2 years ago

gfox1984 commented 2 years ago

I receive a list of points from ELK which I would like to convert to a SVG path. The documentation says that bend points "must be interpreted as control points for a piecewise cubic spline" (which I'm using)

The problem is that the number of bend points + end point that I receive is not always a multiple of 3, so I do not have the required 2 control points + end points to draw all the segments in the curve.

After seeking support on the gitter chat, I was shown the code used in your vscode-extension (and also its Java counterpart):

     case K_SPLINE: {
            path += `M${points[0].x},${points[0].y}`
            for (let i = 1; i < points.length; i = i + 3) {
                const remainingPoints = points.length - i
                if (remainingPoints === 1) {
                    // if one routing point is left, draw a straight line to there.
                    path += `L${points[i].x},${points[i].y}`
                } else if (remainingPoints === 2) {
                    // if two routing points are left, draw a quadratic bezier curve with those two points.
                    path += `Q${points[i].x},${points[i].y} ${points[i + 1].x},${points[i + 1].y}`
                } else {
                    // if three or more routing points are left, draw a cubic bezier curve with those points.
                    path += `C${points[i].x},${points[i].y} `
                        + `${points[i + 1].x},${points[i + 1].y} `
                        + `${points[i + 2].x},${points[i + 2].y}`
                }
            }
            break
        }

While it works, I don't like that for certain number of points (eg: 4 bend points), the code will start drawing a cubic bezier, and end with a quadratic one, which does not produce a symmetric curve (eg see fiddle): image

Another solution proposed was to compute and add extra points when there are not enough points to draw a cubic bezier curve (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)

With this solution, the result is nicer: image

But this is odd that sometimes you need to compute extra points (based on some assumptions), and sometimes you can just use the bend points provided. Therefore I'm asking the question: what is the intent of the library? Is it just a bug in the layout engine that sometimes the number of points is not enough to draw a piecewise cubic bezier curve?

gfox1984 commented 2 years ago

I never received an answer for this, so I'll post the code that I ended up using to compute the SVG path. It's a combination of the code used in ELK and of the answer I received on stackoverflow (see my question for the links). I managed to draw decent splines with that, 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}`;
}

Usage (with an ElkExtendedEdge from elkjs):

const path = edge.sections
  .map(({ startPoint, bendPoints, endPoint }) => getBezierPathFromPoints([startPoint, ...bendPoints, endPoint]))
  .join(' ');