Engineering Journal
Schema Editor
Schema Editor

Name your coordinate spaces or you will silently mix them

2026-08-02

TLDR

Every canvas app has element-local, world, and screen coordinates, and all three are {x, y} objects that pass every type check. Mixing them produces output that is plausible rather than obviously wrong, so it survives review. Name the spaces, name every conversion, and let the function name carry the space.

The problem class

You have a point. Which space is it in?

In an SVG editor, a <path> element's d attribute holds numbers in the element's own coordinate system, before any transform on it or its ancestors. getScreenCTM() maps to pixels. A pan/zoom camera introduces a third space in between: the document, where the drawing "really" lives and where a user thinks about position.

Three spaces. One shape. {x: 140, y: 90} is valid in all of them and means somewhere different in each.

Nothing catches the mistake. There is no unit to disagree, no null to propagate, no exception to throw. A point from the wrong space multiplies through a matrix perfectly happily and produces another point, which renders. You get a picture, just not the one you meant.

This is not SVG-specific. Game engines have local, world, and view space. Layout engines have content box, border box, and viewport. Anything with a camera has it. The failure mode is identical: a function receives a point from the wrong space, does arithmetic that succeeds, and renders something at a coordinate that is real but wrong.

The naive approach

Convert where you need to, inline. It looks harmless:

function drawEndpointHandles(ctx, el) {
  const pts = parsePathPoints(el);                    // from d
  const screen = pts.map(p => worldToScreen(p.x, p.y));
  screen.forEach(p => drawHandle(ctx, p));
}

Four lines, obviously correct, ships. And it is correct, for as long as every path lives directly under the camera group with no transform of its own.

Why it fails

parsePathPoints returns element-local. worldToScreen expects world. Nothing in the code says so.

The bug is invisible while you are building, because content you draw in-app tends to land at the content root with an identity transform, and identity is the one matrix where the two spaces coincide. Then someone imports a file. Every SVG editor on earth wraps content in <g transform="translate(...)">. The handles render at the raw d numbers, which is somewhere else entirely on the canvas.

There is a second symptom that is more diagnostic than the first, and it took me longer than it should have to hear it properly. The handles did not move when the element moved. That follows directly: moving an element rewrites its transform, not its d. If your handle positions are computed from d alone, they are computed from the one thing a move does not change.

I want to be honest about how this got found. The user reported it, proposed exactly this cause, and I checked the code and told them it was not the cause. I had verified the property panel and the bounding-box handle path, and both composed the transform chain correctly. What I missed is that a selected wire renders through a completely different branch than a selected shape, and that branch was the one being reported. "I checked and the transforms are handled correctly" was a claim about the functions I happened to open.

The better model

Make the space part of every name, and give each crossing exactly one implementation.

/* element-local โ†’ document-local /
_elToDoc(el) {
  const svg = this.$svgDisplay[0];
  let m = new DOMMatrix();
  let node = el;
  while (node && node !== svg && node.id !== '_cameraRotGroup') {
    const tv = node.transform?.baseVal;
    if (tv?.length) {
      const lm = tv.consolidate()?.matrix;
      if (lm) m = new DOMMatrix([lm.a, lm.b, lm.c, lm.d, lm.e, lm.f]).multiply(m);
    }
    node = node.parentElement;
  }
  return m;
}

/* document-local โ†’ element-local. Use before writing into d. / _docToEl(el) { try { return this._elToDoc(el).inverse(); } catch (_) { return new DOMMatrix(); } }

Then the accessor everyone actually wants is named for what it returns:

/* A wire's vertices in DOCUMENT space, the form handles and snapping want. /
_wirePointsDoc(el) {
  const pts = this._parsePoints(el);
  if (!pts) return null;
  const m = this._elToDoc(el);
  if (m.isIdentity) return pts;
  return pts.map(p => {
    const t = new DOMPoint(p.x, p.y).matrixTransform(m);
    return { x: t.x, y: t.y };
  });
}

The isIdentity shortcut matters more than it looks. It is the fast path for in-app content, and it is also the reason the bug hid: when the matrix is identity the correct code and the broken code produce the same answer.

The fix at the call site becomes a one-word diff, which is the point:

- const pts = this._parsePathPoints(el);   // element-local
  • const pts = this._wirePointsDoc(el); // document-local

Both directions, always

The read direction is the one you notice, because it is visible. The write direction fails silently.

Dragging a handle gives you a document-space position from the pointer, and writing it straight into d puts the vertex somewhere else:

_moveVertex(el, index, docX, docY) {
  const inv = this._docToEl(el);
  if (!inv.isIdentity) {
    const lp = new DOMPoint(docX, docY).matrixTransform(inv);
    docX = lp.x; docY = lp.y;
  }
  // ... now safe to write into d
}

Round-tripping is the test worth writing. Element (100,120) under translate(880,830) should draw at document (980,950); dragging to document (1100,900) should store (220,70), which must read back as exactly (1100,900).

The audit is the real work

Fixing the reported function is not the fix. Once the spaces have names you can grep for every place they meet, and in my case the same mismatch was in five more:

Every one of those was reachable, none had been reported, and all of them only misbehave on transformed content. That is the signature of this bug class: it is invisible on the content you create while developing, and universal on the content your users import.

Tradeoffs

There is a diagnostic worth keeping from this. If a symptom appears only on imported or pasted content and never on content created in-app, suspect a space mismatch before you suspect the importer. In-app content usually lands with an identity transform, and identity is precisely where the wrong code and the right code agree.

You pay in verbosity. _wirePointsDoc(el) is longer than parsePoints(el), and there is now a conversion in places that previously had none. The matrix walk costs a little, though the identity shortcut covers the common case.

You also cannot fully enforce it in plain JavaScript. Branded types in TypeScript (type DocPoint = {x: number, y: number} & {__space: 'doc'}) would make a mismatch a compile error, and if you are in TS, do that. Without it, naming is the enforcement, which means it holds exactly as long as people keep following it.

The habit that actually sticks is smaller than the architecture: when a function takes or returns a point, say which space in the name or the line above it. Most of these bugs are not hard to fix. They are hard to see, and a name is what makes them visible.

Read this post in the full Engineering Journal โ†’