Copied links with adjusting: End maintain original absolute point coordinates during Option+drag copy

When using Option+drag (Mac) or Ctrl+drag (Windows) to copy a selection that includes a link with adjusting: LinkAdjusting.End, the copied link maintains the original link’s absolute point coordinates
instead of recalculating its route relative to the copied nodes.

Use case:

We allow users to manually reshape orthogonal links. After reshaping, we set adjusting: LinkAdjusting.End to preserve the user’s manual adjustments when nodes are moved. This works correctly for normal node
movement.

Steps to reproduce:

  1. Create a diagram with 2 nodes connected by an orthogonal link
  2. Reshape the link by dragging its segment handles
  3. After reshaping, set link.data.adjusting = go.LinkAdjusting.End (we do this in LinkReshapingTool.doDeactivate)
  4. Select both nodes and the link (cmnd + A)
  5. Hold Option (Mac) / Ctrl (Windows) and drag to copy

Actual result:

The copied link visually connects back to the original link’s position. The copied link’s points array contains the same absolute coordinates as the original, causing it to stretch back to those
coordinates.

Expected result:

The copied link should recalculate its route based on the positions of the copied nodes, while ideally preserving the general shape/routing style.

Minimal reproduction:

See attached HTML file.

Question:

What is the recommended approach to handle this scenario? I would expect that it works in the same way as copy pasting and then dragging - keeping

Minimal code for reproduction is attached:

<!DOCTYPE html>
<html>
<head>
  <title>GoJS Link Copy Issue - adjusting: End</title>
  <script src="https://unpkg.com/gojs/release/go.js"></script>
  <style>
    #myDiagramDiv {
      width: 100%;
      height: 600px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
  <h3>Steps to reproduce:</h3>
  <ol>
    <li>Reshape the link by dragging the diamond handles</li>
    <li>Select both nodes and the link (Cmd/Ctrl+A)</li>
    <li>Hold Option (Mac) / Ctrl (Windows) and drag to copy</li>
  </ol>
  <p><strong>Issue:</strong> The copied link visually connects back to the original link's position.</p>
  
  <div id="myDiagramDiv"></div>

  <script>
    const $ = go.GraphObject.make;

    const myDiagram = $(go.Diagram, "myDiagramDiv", {
      "undoManager.isEnabled": true,
      "draggingTool.isCopyEnabled": true,
    });

    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "lightblue", strokeWidth: 2 },
          new go.Binding("fill", "color")
        ),
        $(go.TextBlock,
          { margin: 10, font: "14px sans-serif" },
          new go.Binding("text", "name")
        )
      );

    myDiagram.linkTemplate =
      $(go.Link,
        {
          routing: go.Routing.Orthogonal,
          adjusting: go.LinkAdjusting.None,
          reshapable: true,
          resegmentable: true,
        },
        new go.Binding("adjusting"),
        new go.Binding("points").makeTwoWay(),
        $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
        $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
      );

    // Set adjusting to End after reshaping to preserve manual adjustments
    class CustomLinkReshapingTool extends go.LinkReshapingTool {
      doDeactivate() {
        const link = this.adornedLink;
        if (link) {
          myDiagram.model.set(link.data, "adjusting", go.LinkAdjusting.End);
        }
        super.doDeactivate();
      }
    }
    myDiagram.toolManager.linkReshapingTool = new CustomLinkReshapingTool();

    myDiagram.model = new go.GraphLinksModel(
      [
        { key: 1, name: "Node 1", color: "lightblue" },
        { key: 2, name: "Node 2", color: "lightgreen" }
      ],
      [
        { from: 1, to: 2 }
      ]
    );

    myDiagram.findNodeForKey(1).location = new go.Point(100, 100);
    myDiagram.findNodeForKey(2).location = new go.Point(300, 200);
  </script>
</body>
</html>

Hmmm, that does seem to be the case. We’ll look into it.

By the way, I see that you have customized the LinkReshapingTool to set Link.adjusting on the modified Link. The way that you do it works, but it’s easier to set Link.adjusting after the reshaping has finished, instead of before in an override of doActivate. Here’s your code, updated:

<!DOCTYPE html>
<html>
<head>
  <title>GoJS Link Copy Issue - adjusting: End</title>
  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <style>
    #myDiagramDiv {
      width: 100%;
      height: 600px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
  <h3>Steps to reproduce:</h3>
  <ol>
    <li>Reshape the link by dragging the diamond handles</li>
    <li>Select both nodes and the link (Cmd/Ctrl+A)</li>
    <li>Hold Option (Mac) / Ctrl (Windows) and drag to copy</li>
  </ol>
  <p><strong>Issue:</strong> The copied link visually connects back to the original link's position.</p>
  
  <div id="myDiagramDiv"></div>

  <script>
    const $ = go.GraphObject.make;

    const myDiagram = $(go.Diagram, "myDiagramDiv", {
      "undoManager.isEnabled": true,
      //"draggingTool.isCopyEnabled": true,
      "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End
    });

    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "lightblue", strokeWidth: 2 },
          new go.Binding("fill", "color")
        ),
        $(go.TextBlock,
          { margin: 10, font: "14px sans-serif" },
          new go.Binding("text", "name")
        )
      );

    myDiagram.linkTemplate =
      $(go.Link,
        {
          routing: go.Routing.Orthogonal,
          //adjusting: go.LinkAdjusting.None,
          reshapable: true,
          resegmentable: true,
        },
        new go.Binding("adjusting"),
        new go.Binding("points").makeTwoWay(),
        $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
        $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
      );

    // // Set adjusting to End after reshaping to preserve manual adjustments
    // class CustomLinkReshapingTool extends go.LinkReshapingTool {
    //   doDeactivate() {
    //     const link = this.adornedLink;
    //     if (link) {
    //       myDiagram.model.set(link.data, "adjusting", go.LinkAdjusting.End);
    //     }
    //     super.doDeactivate();
    //   }
    // }
    // myDiagram.toolManager.linkReshapingTool = new CustomLinkReshapingTool();

    myDiagram.model = new go.GraphLinksModel(
      [
        { key: 1, name: "Node 1", color: "lightblue" },
        { key: 2, name: "Node 2", color: "lightgreen" }
      ],
      [
        { from: 1, to: 2 }
      ]
    );

    myDiagram.findNodeForKey(1).location = new go.Point(100, 100);
    myDiagram.findNodeForKey(2).location = new go.Point(300, 200);
  </script>
</body>
</html>

I also commented out some unnecessary code.

@walter okay, thanks for suggestions!

Looking forward to further updates regarding the bug.

So the problem is that the copied link data object has “adjusting” and “points” set to those two properties of the original link data. Clearly if data.adjusting has an End value, then moving the connected (copied) Nodes will only recompute the points connecting with those Nodes, and not the intermediate points. So the current behavior is correct.

Instead I think you don’t want to copy those two properties of the link data. Here’s your code, updated:

    const $ = go.GraphObject.make;

    const myDiagram = new go.Diagram("myDiagramDiv", {
      "undoManager.isEnabled": true,
      "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End
    });

    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "lightblue", strokeWidth: 2 },
          new go.Binding("fill", "color")
        ),
        $(go.TextBlock,
          { margin: 10, font: "14px sans-serif" },
          new go.Binding("text", "name")
        )
      );

    myDiagram.linkTemplate =
      $(go.Link,
        {
          routing: go.Routing.Orthogonal,
          reshapable: true,
          resegmentable: true,
        },
        new go.Binding("adjusting").makeTwoWay(),
        new go.Binding("points").makeTwoWay(),
        $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
        $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
      );

    myDiagram.model = new go.GraphLinksModel(
      [
        { key: 1, name: "Node 1", color: "lightblue" },
        { key: 2, name: "Node 2", color: "lightgreen" }
      ],
      [
        { from: 1, to: 2 }
      ]
    );
    myDiagram.model.copyLinkDataFunction = (data, model) => {
      const newdata = Object.assign({}, data);
      newdata.adjusting = undefined;
      newdata.points = undefined;
      return newdata;
    };

    myDiagram.findNodeForKey(1).location = new go.Point(100, 100);
    myDiagram.findNodeForKey(2).location = new go.Point(300, 200);

Note that because GraphLinksModel.copyLinkDataFunction is a function, and because functions cannot be serialized into JSON-formatted text, you’ll need to reset that property everytime you load a model.

But this seems to be breaking how link shape looks like, like it wasn’t modified. So when I just move it - link shape preserves, when I copy paste it - shape preserves, but when I copy via option and drag - link shape will be reset.

OK, try this code, which overrides Diagram.copyParts when its called by DraggingTool so that the temporary copied Links are not routed during the copy-drag.

    const $ = go.GraphObject.make;

    const myDiagram = $(go.Diagram, "myDiagramDiv", {
      // override Diagram.copyParts to prevent routing temporary Links by DraggingTool calls to moveParts
      copyParts: function(coll, diagram, check) {
        const map = go.Diagram.prototype.copyParts.call(this, coll, diagram, check);
        if (this.currentTool instanceof go.DraggingTool) {
          map.iteratorValues.each(part => {
            if (part instanceof go.Link) part.suspendsRouting = true;
          });
        }
        return map;
      },
      "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End,
      "undoManager.isEnabled": true
    });

    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        { locationSpot: go.Spot.Center },
        $(go.Shape, "RoundedRectangle",
          { fill: "lightblue", strokeWidth: 2 },
          new go.Binding("fill", "color")
        ),
        $(go.TextBlock,
          { margin: 10, font: "14px sans-serif" },
          new go.Binding("text", "name")
        )
      );

    myDiagram.linkTemplate =
      $(go.Link,
        {
          routing: go.Routing.Orthogonal,
          reshapable: true,
          resegmentable: true,
        },
        new go.Binding("adjusting").makeTwoWay(),
        new go.Binding("points").makeTwoWay(),
        $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
        $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
      );

    myDiagram.model = new go.GraphLinksModel(
      [
        { key: 1, name: "Node 1", color: "lightblue" },
        { key: 2, name: "Node 2", color: "lightgreen" }
      ],
      [
        { from: 1, to: 2 }
      ]
    );

    myDiagram.findNodeForKey(1).location = new go.Point(100, 100);
    myDiagram.findNodeForKey(2).location = new go.Point(300, 200);

It seems that copyParts did the trick.

Thank you @walter

Hello, again! I found one more thing, but I can’t seem to reproduce it on my test snippet.

The problem is that having isGridSnapEnabled = true in DraggingTool in my real project - makes this copyParts fix not to be working. When I disable it - everything works as expected. Do you have any ideas?

When that override of Diagram.copyParts is there and DraggingTool.isGridSnapEnabled is true, under precisely which conditions do things work well and which conditions do they not?

When I only create new shapes and link between them and try to copy them via drag + option - it never works correctly, but when I try to copy already copied link with shape - it works fine, link is NOT maintaining it’s original points

Maybe your templates aren’t initialized the way that you want?

I’m unable to reproduce the problem. Here’s my updated version of your code that supports creating new nodes (via double-click in the background), and drawing new links between nodes, and editing text, and reloading the model. That’s to see if how the nodes and links are created matters, which we agree it shouldn’t.

<!DOCTYPE html>
<html>
<head>
  <title>GoJS Link Copy Issue - adjusting: End</title>
  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <style>
    #myDiagramDiv {
      width: 100%;
      height: 600px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
  <h3>Steps to reproduce:</h3>
  <ol>
    <li>Reshape the link by dragging the diamond handles</li>
    <li>Select both nodes and the link (Cmd/Ctrl+A)</li>
    <li>Hold Option (Mac) / Ctrl (Windows) and drag to copy</li>
  </ol>
  <p><strong>Issue:</strong> The copied link visually connects back to the original link's position.</p>
  
  <div id="myDiagramDiv"></div>
  <button id="myTestButton">Reload</button>

  <script>
    const $ = go.GraphObject.make;

    const myDiagram = $(go.Diagram, "myDiagramDiv", {
      "clickCreatingTool.archetypeNodeData": { name: "NEW", color: "yellow" },
      // override Diagram.copyParts to prevent routing temporary Links by DraggingTool calls to moveParts
      copyParts: function(coll, diagram, check) {
        const map = go.Diagram.prototype.copyParts.call(this, coll, diagram, check);
        if (this.currentTool instanceof go.DraggingTool) {
          map.iteratorValues.each(part => {
            if (part instanceof go.Link) part.suspendsRouting = true;
          });
        }
        return map;
      },
      "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End,
      "undoManager.isEnabled": true
    });

    myDiagram.nodeTemplate =
      $(go.Node, "Auto",
        { locationSpot: go.Spot.Center },
        new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
        $(go.Shape, "RoundedRectangle",
          {
            fill: "lightblue", strokeWidth: 2,
            portId: "", fromLinkable: true, toLinkable: true, cursor: "pointer"
          },
          new go.Binding("fill", "color")
        ),
        $(go.TextBlock,
          { margin: 10, font: "14px sans-serif", editable: true },
          new go.Binding("text", "name").makeTwoWay()
        )
      );

    myDiagram.linkTemplate =
      $(go.Link,
        {
          routing: go.Routing.Orthogonal,
          reshapable: true,
          resegmentable: true,
        },
        new go.Binding("adjusting").makeTwoWay(),
        new go.Binding("points").makeTwoWay(),
        $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
        $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
      );

    myDiagram.model = new go.GraphLinksModel(
      [
        { key: 1, name: "Node 1", color: "lightblue" },
        { key: 2, name: "Node 2", color: "lightgreen" }
      ],
      [
        { from: 1, to: 2 }
      ]
    );

    myDiagram.findNodeForKey(1).location = new go.Point(100, 100);
    myDiagram.findNodeForKey(2).location = new go.Point(300, 200);

document.getElementById("myTestButton").addEventListener("click", e => {
  myDiagram.model = go.Model.fromJson(myDiagram.model.toJson());
});
</script>
</body>
</html>

It seems that I am close to finding real issue. It’s something connected to nodes, not links. But I still can’t understand what exactly. But nevermind, thanks for help!

Still, I want to clear out that this initial issue is not a bug, but rather expected behavior and it’s not going to be fixed in the future? In order to handle this situation we have to use that “copyParts” overriding. Is this correct?

Please, try this code

<!DOCTYPE html>
<html>
<head>
  <title>GoJS Link Copy Issue - adjusting: End</title>
  <script src="https://unpkg.com/gojs/release/go.js"></script>
  <style>
    #myDiagramDiv {
      width: 100%;
      height: 600px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
<h3>Steps to reproduce:</h3>
<ol>
  <li>Reshape the link by dragging the diamond handles</li>
  <li>Select both nodes and the link (Cmd/Ctrl+A)</li>
  <li>Hold Option (Mac) / Ctrl (Windows) and drag to copy</li>
</ol>
<p><strong>Issue:</strong> The copied link visually connects back to the original link's position.</p>

<div id="myDiagramDiv"></div>

<script>
  const $ = go.GraphObject.make;

  const myDiagram = $(go.Diagram, "myDiagramDiv", {
    // override Diagram.copyParts to prevent routing temporary Links by DraggingTool calls to moveParts
    copyParts: function(coll, diagram, check) {
      const map = go.Diagram.prototype.copyParts.call(this, coll, diagram, check);
      if (this.currentTool instanceof go.DraggingTool) {
        map.iteratorValues.each(part => {
          if (part instanceof go.Link) part.suspendsRouting = true;
        });
      }
      return map;
    },
    "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End,
    "undoManager.isEnabled": true,
    "draggingTool.isGridSnapEnabled": true
  });

  myDiagram.nodeTemplate =
    $(go.Node, "Auto",
      { locationSpot: go.Spot.Center },
      $(go.Shape, "RoundedRectangle",
        { fill: "lightblue", strokeWidth: 2 },
        new go.Binding("fill", "color")
      ),
      $(go.TextBlock,
        { margin: 10, font: "14px sans-serif" },
        new go.Binding("text", "name")
      )
    );

  myDiagram.linkTemplate =
    $(go.Link,
      {
        routing: go.Routing.Orthogonal,
        adjusting: go.LinkAdjusting.None,
        reshapable: true,
        resegmentable: true,
      },
      new go.Binding("adjusting"),
      new go.Binding("points").makeTwoWay(),
      $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
      $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
    );

  // Set adjusting to End after reshaping to preserve manual adjustments
  class CustomLinkReshapingTool extends go.LinkReshapingTool {
    doDeactivate() {
      const link = this.adornedLink;
      if (link) {
        myDiagram.model.set(link.data, "adjusting", go.LinkAdjusting.End);
      }
      super.doDeactivate();
    }
  }
  myDiagram.toolManager.linkReshapingTool = new CustomLinkReshapingTool();

  // First create nodes without link
  myDiagram.model = new go.GraphLinksModel(
    [
      { key: 1, name: "Node 1", color: "lightblue" },
      { key: 2, name: "Node 2", color: "lightgreen" }
    ],
    []
  );

  // Set high-precision floating-point coordinates BEFORE creating link
  myDiagram.findNodeForKey(1).location = new go.Point(100.6782565298753, 100.5991165389759);
  myDiagram.findNodeForKey(2).location = new go.Point(300.099369404706, 200.8191165389759);

  // Now add the link - it will be routed with high-precision coordinates
  myDiagram.model.addLinkData({ from: 1, to: 2 });
</script>
</body>
</html>

The problem is decimal points for links/nodes when grid snap enabled. But it seems to be like a possible case, as user can have snapping off - modify nodes/links and then turn it on.
Please review it @walter

The problem is that your initial node locations are not all grid-aligned or evenly offset from grid points.

In the following code I have three sets of model data that are loaded. The first one is basically what I provided above, where the node locations are at grid points. The second one is the same, but all of the node locations have the same offset from grid points. The third one is your case, where the node locations have different offsets from grid points, which causes the nodes to be shifted different distances due to grid-snapping behavior while dragging.

One solution is to make sure that those node locations are evenly offset from grid points, as the following code shows after loading the model.

I have simplified your code a bit – there’s no need for that CustomLinkReshapingTool as long as there’s a TwoWay Binding on the Link.adjusting property. It really shouldn’t be setting Link.adjusting at the start of the RelinkingTool’s operation, anyway. And I added a binding on Node.location for generality.

<!DOCTYPE html>
<html>
<head>
  <title>GoJS Link Copy Issue - adjusting: End</title>
  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <style>
    #myDiagramDiv {
      width: 100%;
      height: 600px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
<h3>Steps to reproduce:</h3>
<ol>
  <li>Reshape the link by dragging the diamond handles</li>
  <li>Select both nodes and the link (Cmd/Ctrl+A)</li>
  <li>Hold Option (Mac) / Ctrl (Windows) and drag to copy</li>
</ol>
<p><strong>Issue:</strong> The copied link visually connects back to the original link's position.</p>

<div id="myDiagramDiv"></div>

<script>
  const $ = go.GraphObject.make;

  const myDiagram = $(go.Diagram, "myDiagramDiv", {
    "draggingTool.isGridSnapEnabled": true,
    // override Diagram.copyParts to prevent routing temporary Links by DraggingTool calls to moveParts
    copyParts: function(coll, diagram, check) {
      const map = go.Diagram.prototype.copyParts.call(this, coll, diagram, check);
      if (this.currentTool instanceof go.DraggingTool) {
        map.iteratorValues.each(part => {
          if (part instanceof go.Link) part.suspendsRouting = true;
        });
      }
      return map;
    },
    "LinkReshaped": e => e.subject.adjusting = go.LinkAdjusting.End,
    "undoManager.isEnabled": true,
  });

  myDiagram.nodeTemplate =
    $(go.Node, "Auto",
      { locationSpot: go.Spot.Center },
      new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify),
      $(go.Shape, "RoundedRectangle",
        { fill: "lightblue", strokeWidth: 2 },
        new go.Binding("fill", "color")
      ),
      $(go.TextBlock,
        { margin: 10, font: "14px sans-serif" },
        new go.Binding("text", "name")
      )
    );

  myDiagram.linkTemplate =
    $(go.Link,
      {
        routing: go.Routing.Orthogonal,
        adjusting: go.LinkAdjusting.None,
        reshapable: true,
        resegmentable: true,
      },
      new go.Binding("adjusting").makeTwoWay(),
      new go.Binding("points").makeTwoWay(),
      $(go.Shape, { strokeWidth: 2, stroke: "gray" }),
      $(go.Shape, { toArrow: "Standard", stroke: "gray", fill: "gray" })
    );

  // First create nodes without link
  myDiagram.model = new go.GraphLinksModel(
    [

      // node locations always on grid points [OK]
      //{ key: 1, name: "Node 1", color: "lightblue", loc: "100 100" },
      //{ key: 2, name: "Node 2", color: "lightgreen", loc: "300 200" }

      // node locations always offset by constant distance from grid points [OK]
      //{ key: 1, name: "Node 1", color: "lightblue", loc: "100.6782565298753 100.5991165389759" },
      //{ key: 2, name: "Node 2", color: "lightgreen", loc: "300.6782565298753 200.5991165389759" }

      // grid snapping will cause nodes to shift by varying amounts, requiring link-rerouting [MAYBE PROBLEM]
      { key: 1, name: "Node 1", color: "lightblue", loc: "100.6782565298753 100.5991165389759" },
      { key: 2, name: "Node 2", color: "lightgreen", loc: "300.099369404706 200.8191165389759" }

    ],
    [{ from: 1, to: 2 }]
  );

// if node locations might not be evenly grid-aligned, make them so:
myDiagram.moveParts(new go.List(myDiagram.nodes).addAll(myDiagram.links), new go.Point(0, 0));

myDiagram.select(myDiagram.links.first());
</script>
</body>
</html>

So I guess the basic issue is whether it’s OK to have nodes with the wrong offset from grid points if you have turned on DraggingTool.isGridSnapEnabled.

It’s still not 100% clear to me, what exactly would happen if user disable gridSnap, moves nodes, creates link and enable gridSnap. This exactly how that case would happen.

So the only solution is to normalize location of parts when we enable gridSnap?

Well, that’s one solution. Maybe only selected nodes should be aligned in the doActivate override. But I think it’s easier to understand that turning on grid snapping also aligns all nodes. Which would require a transaction when grid snapping is turned on.

But it does not seems to be right. What if user wants to preserve all the current elements and work only with copy of it.
For me it seems that this issue should be resolved on your side rather in the project, it must be expected that not depending of how “precise” linkPoints are - bug is not reproducable, don’t you agree?

Are you talking about preserving the (manually adjusted) link route(s) both when moving and when copying, even though the nodes move varying amounts, causing the links to no longer be connected properly with their nodes? So there might be gaps or overlaps of links with their connected nodes?

Or would you want the node locations not to be snapped to the grid in some cases?

The problem is the following:

  1. I have nodes whose locations are not snapped to the grid, and there are links between those nodes.

  2. When the user turns grid snapping on, the user expectation is that existing elements keep their current positions and do not move (“jiggle”) at that moment.

  3. After grid snapping is enabled, the user creates a copy of those nodes.

    • The copied nodes are snapped to the grid, which is correct and expected.
  4. However, the links in the copied parts preserve their original link points, as if no copyParts / link-point adjustment logic was applied.

    • This results in incorrect link routing for the copied nodes, even though the copies are snapped.

So effectively, enabling grid snapping without aligning existing nodes causes a mismatch:

  • Original nodes stay unsnapped (by design),

  • Copied nodes snap to the grid,

  • But the copied links still use link points from the original (middle points)

From a user perspective, this is a very reasonable workflow.

We will investigate implementing different behavior when adjusting is End.

[EDIT] I think this case will be handled more to your liking in 3.1.4. That won’t be released until next week (next year).