Link Corners on non-orthogonal segments - Take 2

Hello,

a few months back I asked for help here to add an arbitrary number of non-orthogonal link bend points with some radius on them. We got to an acceptable solution for a read-only diagram, i.e.: the diagram model would be loaded onto gojs and the user could not modify it.

An example looks like this:
Screenshot 2021-02-03 at 10.27.31

But now we are making the gojs diagram editable and the current solution doesn’t work properly as I’ll try to demonstrate.

Some context:
We’re trying to replicate a legacy app link routing behavior which is not supported by gojs out-of-the-box. Therefore, the only option is to extend the go.Link methods: makeGeometry and computePoints

Some code:

import * as go from 'gojs';

class Vector {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x
    this.y = y
  }

  sub(vector: Vector): Vector {
    return new Vector(this.x - vector.x, this.y - vector.y);
  }

  add(vector: Vector): Vector {
    return new Vector(this.x + vector.x, this.y + vector.y);
  }

  scale(n: number): Vector {
    return new Vector(this.x * n, this.y * n);
  }

  length(): number {
    return Math.sqrt(this.x ** 2 + this.y ** 2);
  }

  normal(): Vector {
    const length = this.length();
    return length ? new Vector(this.x / length, this.y / length) : new Vector(0, 0);
  }
}

export class MiradiRouteLink extends go.Link {

  /**
   * A line from vecFrom to vecTo
  */
  private line(vecFrom: Vector, vecTo: Vector): Vector[] {
    let halfVec = vecFrom.add(vecTo.sub(vecFrom).scale(.5));
    return [halfVec, vecTo];
  }

  // Adds 'n' units to vecFrom pointing in direction vecTo
  private vecDir(vecFrom: Vector, vecTo: Vector, n: number) {
    let vec = vecTo.sub(vecFrom).normal().scale(n);
    return vecFrom.add(vec);
  }

  // Draws a line, but skips 'r' units from the begin and end
  private lineR(vecFrom: Vector, vecTo: Vector, r: number) {
    let vec = vecTo.sub(vecFrom).normal().scale(r);
    return this.line(vecFrom.add(vec), vecTo.sub(vec));
  }

  // An edge in vecFrom, to vecTo with radius r
  private edge(vecFrom: Vector, vecTo: Vector, r: number) {
    let v = this.vecDir(vecFrom, vecTo, r);
    return [vecFrom, v];
  }
  
  public makeGeometry(): go.Geometry {
    if (this.pointsCount <= 2) {
      return super.makeGeometry();
    } else { 
      // const superGeo = super.makeGeometry(); console.log((superGeo as any).flattenedSegments[0]); console.log(superGeo.figures.first().segments.length)
      // Start by creating the PathFigure with the desired start point
      const startPoint = this.points.first();
      const pathFigure = new go.PathFigure(startPoint.x, startPoint.y, false);

      // For each middle point, add a line segment from the previous point to a few "pixels" short of the next point;
      // Then add a curvature (QuadraticBezier) in the direction of the next point
      for (let i = 1; i < this.pointsCount - 1; i ++) {
        const vectorFrom = new Vector(this.getPoint(i - 1).x, this.getPoint(i - 1).y);
        const vectorTo = new Vector(this.getPoint(i).x, this.getPoint(i).y);
        const vectorNext = new Vector(this.getPoint(i + 1).x, this.getPoint(i + 1).y);

        const distToNext = vectorNext.sub(vectorTo).length();
        const radius = distToNext < this.corner * 2 ? distToNext / 2 : this.corner;
        const line = this.lineR(vectorFrom, vectorTo, radius);
        const edge = this.edge(vectorTo, vectorNext, radius);
  
        pathFigure.add(new go.PathSegment(go.PathSegment.Line, line[1].x, line[1].y));
        pathFigure.add(new go.PathSegment(go.PathSegment.QuadraticBezier, edge[1].x, edge[1].y, edge[0].x, edge[0].y));
        // pathFigure.add(new go.PathSegment(go.PathSegment.Line, edge[1].x, edge[1].y, edge[0].x, edge[0].y));
      }

      // Finish by adding the end point to the PathFigure
      const endPoint = this.points.last();
      pathFigure.add(new go.PathSegment(go.PathSegment.Line, endPoint.x,endPoint.y));

      const geometry = new go.Geometry();
      geometry.add(pathFigure);

      const normalizeOffset = geometry.normalize(); // console.log((geometry as any).flattenedSegments[0]); console.log(geometry.figures.first().segments.length);

      return geometry;
    }
  }

  /**
   * Constructs the link's route by modifying {@link #points}.
   * @return {boolean} true if it computed a route of points
   */
  public computePoints(): boolean {
    if (!this.data.bendPoints?.length) {
      return super.computePoints();
    } else if (!this.fromNode) {
      throw `Couldn't find from_diagram_factor '${this.data.from}' referenced by link '${this.data.key}' on project '${this.data.projectIdentifier}'.`;
    } else if (!this.toNode) {
      throw `Couldn't find to_diagram_factor '${this.data.to}' referenced by link '${this.data.key}' on project '${this.data.projectIdentifier}'.`;
    } else {
      const firstBendPoint = new go.Point(this.data.bendPoints[0].x, this.data.bendPoints[0].y);
      const lastBendPoint = new go.Point(this.data.bendPoints[this.data.bendPoints.length - 1].x, this.data.bendPoints[this.data.bendPoints.length - 1].y);

      const startPoint = this.getLinkPointFromPoint(this.fromNode, this.fromPort, this.fromPort.getDocumentPoint(go.Spot.Center), firstBendPoint, true);
      const lastPoint = this.getLinkPointFromPoint(this.toNode, this.toPort, this.toPort.getDocumentPoint(go.Spot.Center), lastBendPoint, false);
      
      this.clearPoints();
      this.addPoint(startPoint);
      for (const bendPoint of this.data.bendPoints) {
        this.addPointAt(bendPoint.x, bendPoint.y);
      }
      this.addPoint(lastPoint);

      return true;
    }
  }
}

Problem:
Creating geometry figures in code, doing vector math is not my strong suit, but a couple of issues stand out as not making much sense, and I’m thinking this could be a gojs library limitation? Or that I’m missing something important in the way gojs geometries / figures / segments work…

The code above specifies exactly the startPoint and endPoint. That’s both in the computePoints and in makeGeometry methods. In makeGeometry, the figure starts at startPoint, and the endPoint is added as the last point to the figure.

So why, when the user moves a link point, the startPoint and endPoint (that shouldn’t change at all) move around in the diagram? Here’s it in action:

Note: I’ve removed the rounded corners to better illustrate the issue.
You can clearly see that the start point and end point move around and shouldn’t! It’s like the first segment has a fixed length and since the bend point corner changes position, the corresponding segment, keeping the same length, drags the start point with it.

What am I doing wrong? Am I wrong in thinking that even if I got the math wrong to calculate the point where the link bends, the start point should not change, since I’m always passing the same exact coordinates?

Thanks,
Nuno

Ok, I think I got it to work…

The problem was in calling geometry.normalize();

I don’t know what you’re doing there but replacing that call with my own geometry offset to (0,0) works nicely (calculating the minX and minY)!

See updated code:

public makeGeometry(): go.Geometry {
    if (this.pointsCount <= 2) {
      return super.makeGeometry();
    } else { 
      let minX = Infinity;
      let minY = Infinity;
      
      // Start by creating the PathFigure with the desired start point
      const startPoint = this.points.first();
      const pathFigure = new go.PathFigure(startPoint.x, startPoint.y, false);
      minX = Math.min(minX, startPoint.x);
      minY = Math.min(minY, startPoint.y);

      // For each middle point, add a line segment from the previous point to a few "pixels" short of the next point;
      // Then add a curvature (QuadraticBezier) in the direction of the next point
      for (let i = 1; i < this.pointsCount - 1; i ++) {
        const vectorFrom = new Vector(this.getPoint(i - 1).x, this.getPoint(i - 1).y);
        const vectorTo = new Vector(this.getPoint(i).x, this.getPoint(i).y);
        const vectorNext = new Vector(this.getPoint(i + 1).x, this.getPoint(i + 1).y);

        const distToNext = vectorNext.sub(vectorTo).length();
        const radius = distToNext < (this.corner << 1) ? (distToNext >> 1) : this.corner;
        const line = this.lineR(vectorFrom, vectorTo, radius);
        const edge = this.edge(vectorTo, vectorNext, radius);
  
        pathFigure.add(new go.PathSegment(go.PathSegment.Line, line[1].x, line[1].y));
        pathFigure.add(new go.PathSegment(go.PathSegment.QuadraticBezier, edge[1].x, edge[1].y, edge[0].x, edge[0].y));

        minX = Math.min(minX, edge[0].x);
        minY = Math.min(minY, edge[0].y);
      }

      // Finish by adding the end point to the PathFigure
      const endPoint = this.points.last();
      pathFigure.add(new go.PathSegment(go.PathSegment.Line, endPoint.x,endPoint.y));
      minX = Math.min(minX, endPoint.x);
      minY = Math.min(minY, endPoint.y);

      const geometry = new go.Geometry();
      geometry.add(pathFigure);

      // const normalizeOffset = geometry.normalize(); // this shifts the start and end points if the bend points are not orthogonal...
      geometry.offset(-minX, -minY);

      return geometry;
    }
  }

and example:

So please be aware that there may be some undesired behavior in your normalize() method.

Thanks,
Nuno

That’s very good. I’m glad you figured out a solution.

My guess is that the Bezier curve(s) control point(s) are causing the normalize to shift by an unexpected amount given your situations.

is this can solve this ?