Engineering Journal
Schema Editor
Schema Editor

Automatic layout should not outrank the geometry a user drew

2026-08-02

TLDR

If your editor has an automatic layout function, the dangerous part is not the algorithm, it is the list of places that call it. An edit that changes one point should change one point, so prefer re-anchoring a vertex over regenerating a path. Reserve full regeneration for gestures that explicitly ask for it.

The problem class

Any editor where a human arranges things by hand and code also arranges things automatically has to answer one question repeatedly: when the automatic thing runs, whose work does it overwrite?

This shows up in diagram tools with edge routers, in IDEs with auto-format on save, in graph editors with force layout, and in CAD with constraint solvers. The algorithm is usually fine. The bug is that somebody wired it into an event that never asked for it.

I hit this in a browser based schematic editor. Wires connect component pins, and an orthogonal router can compute a clean path between two pins. Reasonable. What went wrong was where that router got called.

The naive approach

The obvious way to keep an attached wire correct when a component moves is to recompute it. The endpoints are known, the router knows how to connect two points, so ask it again:

function updateAttachedWire(wire) {
  const fromPin = resolvePinPosition(wire.dataset.fromSym, wire.dataset.fromPin);
  const toPin   = resolvePinPosition(wire.dataset.toSym,   wire.dataset.toPin);
  wire.setAttribute('d', smartRoute(fromPin, toPin));
}

Four lines, always correct, no state to maintain. It is genuinely appealing. And for a wire that is a straight hop between two pins it is indistinguishable from the right answer, which is exactly why it survives review.

Why it fails

It fails the moment a wire has any shape the user chose.

Rotating a component moves a pin by a few units. That call then discards every bend the user placed and replaces the whole path with the router's guess. From the user's chair, they nudged one thing and their layout rearranged itself.

The same function was reachable from three gestures: moving a component, cutting a wire, and inserting a junction. Each produced its own complaint. "Wires mess up when I rotate." "Cutting redraws both halves." "The wire goes through the symbol I just dropped on it." Three bug reports, one cause, which is the signature of a shared function called from somewhere it does not belong.

That third one is worth dwelling on. Cutting a wire and inserting a component re-routed the far half starting from the new component's exit pin. The router had no notion of the component's body, so it cheerfully drew the wire straight through it. The feature that inserted the component was also the feature that ruined the drawing.

The better model

State the rule as a sentence about information: a transform tells you something new about one point, so change one point.

A component moving does not tell you anything about the wire's midpoints. It tells you where one endpoint now is. Everything else the user drew is still true and should still be there.

Concretely, re-anchor instead of regenerate:

// Move one end of a polyline to newPt, keeping every other vertex.
function reanchorEnd(pts, which, newPt) {
  const EPS = 0.5;
  const i = which === 'from' ? 0 : pts.length - 1;
  const j = which === 'from' ? 1 : pts.length - 2;
  const old = pts[i];
  pts[i] = { x: newPt.x, y: newPt.y };
  if (j < 0 || j >= pts.length) return pts;

const nb = pts[j]; if (Math.abs(nb.x - old.x) < EPS) pts[j] = { x: newPt.x, y: nb.y }; else if (Math.abs(nb.y - old.y) < EPS) pts[j] = { x: nb.x, y: newPt.y }; return pts; // diagonal terminal segment: nothing to preserve }

Two details carry the weight here. The EPS comparison asks what the terminal segment was, since that is the constraint worth preserving, and the diagonal case deliberately does nothing rather than guessing at an orientation that never existed.

The neighbour adjustment is the part that makes this feel correct rather than merely conservative. Without it, moving an endpoint turns the last segment diagonal and the path stops looking like the orthogonal drawing it was. With it, the shape is preserved and the constraint is preserved.

Results against a fixture polyline, where the wire runs from a pin at the origin, elbows, and lands at (200, 100):

original    M 0 0 L 0 100 L 200 100
pin moves   M 0 0 L 0 85  L 195 85     endpoint follows, still orthogonal
multi-bend  M 0 0 L 0 50 L 80 50 L 80 130 L 210 130    all three bends survive

The same principle rewrites the cut operation. Splitting a path is a slice, not a regeneration:

function splitPolylineAt(coords, pt) {
  const loc = locateOnPolyline(coords, pt);   // {index, point}, projected
  const p = { x: loc.point.x, y: loc.point.y };
  return {
    left:  coords.slice(0, loc.index + 1).concat([p]),
    right: [p].concat(coords.slice(loc.index + 1)),
  };
}

One detail there cost me a bug. The projection function returned both the segment index and the corrected point, and my first draft used the index while inserting the raw click coordinates. A click three pixels off the line became a vertex three pixels off the line, kinking both halves. The output was still a valid path, which is what makes this class of mistake survive a code read. Running the helper as a pure function against a fixture and comparing the literal output string found it in seconds.

What to do with the router

Do not delete it. Make it something the user invokes.

Auditing the call sites, exactly one gesture genuinely meant "re-route": dragging a wire itself between two fixed components, where the drag is the user asking for a new path. That kept the router. Everything else moved to re-anchoring, and full regeneration became an explicit command on a keyboard shortcut.

The test for any call site: did this gesture ask for a layout? Moving a component asks to move a component. Cutting a wire asks to cut a wire. Neither is consent to redraw.

This is worth doing as an explicit audit rather than case by case, because the call sites accumulated over months and each one looked locally reasonable when it was added. Grep for the router, list every caller, and make each justify itself against that question.

Tradeoffs

Preserved geometry can be ugly geometry. If you drag a component a long way, a re-anchored wire keeps its old shape stretched across the new distance, where the router would have produced something tidy. That is a real cost, and the mitigation is making the tidy-up one keystroke away rather than automatic.

You also give up a pleasant property of the naive version: it is stateless. Re-anchoring reads the current path each frame and mutates it, so a long drag applies many small updates rather than one idempotent recomputation. In practice this is stable, because after the first frame the terminal segment is already axis-aligned and later frames are no-ops on the neighbour. But it is worth knowing you traded idempotence for preservation.

Finally, some domains genuinely want the router to win. If your paths are generated rather than drawn, and no human ever positioned a bend, regeneration is simpler and correct. The rule is not "never re-route." It is that the moment a human can author geometry, that geometry is data, and automatic layout does not get to overwrite data without being asked.

Read this post in the full Engineering Journal →