Engineering Journal
Schema Editor
Schema Editor

Spatial Queries Fail Quietly When Geometry Lives in Two Coordinate Spaces

2026-07-17

TLDR

Any editor that mixes transformed groups with flat geometry will eventually compare coordinates from two different spaces. The comparison does not throw. It returns false for everything, and your feature reports confident nonsense. The fix is a discipline, not a patch: every spatial query projects its inputs into one canonical space before comparing anything.


The problem class

This applies to any canvas or scene-graph app: diagram editors, CAD tools, game level editors, annotation UIs. You have two kinds of geometry.

Flat geometry is authored in document coordinates. A wire drawn by a user gets a path like M 530 300 L 770 300. Those numbers mean what they say.

Transformed geometry is authored in local coordinates and positioned by a transform. A component symbol is defined around its own origin, spanning roughly -32 to 32, and placed with translate(500, 300). Its numbers do not mean what they say. They mean "after you apply my transform chain."

Any feature that asks "is this near that" across the two kinds is a spatial query. Hit testing, snapping, connectivity, collision, marquee selection. Every one of them needs both operands in the same space.

The naive approach

The obvious way to get a shape's bounds in the browser is getBBox():

const b = element.getBBox();
const bbox = { x: b.x, y: b.y, width: b.width, height: b.height };

Then the query looks reasonable:

if (endpoint.x >= bbox.x - margin && endpoint.x <= bbox.x + bbox.width + margin) {
    // endpoint touches this component
}

This code shipped. It also passed testing, which is the interesting part.

Why it fails

getBBox() returns the box in the element's own local coordinate system. It ignores the element's transform attribute entirely. That is by spec, not a browser quirk.

For flat geometry, local space and document space are the same thing, so the code works. For a symbol placed with translate(500, 300), getBBox() returns x: -32, width: 64. The wire endpoint being tested sits at x: 530. The overlap test compares 530 against a box that ends at 32 and returns false, forever, for every component on the canvas.

The failure mode is what makes this class nasty. Nothing crashes. The connectivity checker runs, produces a full report, and every line of it says "0/2 pins connected" about a circuit that is visibly wired correctly on screen. Confident output, wrong in every row.

The better model

Pick one canonical space. For an SVG editor the natural choice is document space, the coordinate system your flat geometry already lives in. Then enforce one rule: geometry crosses a query boundary only after projection into canonical space.

The projection is a matrix walk up the ancestor chain, consolidating each transform:

function worldMatrix(el, stopNode) {
    let m = new DOMMatrix();
    let node = el;
    while (node && node !== stopNode) {
        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;
}

The stopNode matters. If your editor has a camera implemented as a top-level transform group, you stop below it. Otherwise pan and zoom leak into your "document" coordinates and you have invented a third space.

Boxes project corner by corner, because a rotated box's axis-aligned bounds are not the transform of its original corners taken pairwise:

const wm = worldMatrix(symbol, cameraGroup);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [px, py] of corners(localBBox)) {
    const p = new DOMPoint(px, py).matrixTransform(wm);
    minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
    minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}

Point features beat box features

Once the projection exists, a second improvement becomes cheap. The connectivity query never actually wanted "is the endpoint near the component's box." It wanted "is the endpoint on a pin."

Boxes are a proxy, and a bad one: a wire that grazes a component body counts as connected, and two wires landing on the same pin count as two satisfied connections. Projecting the pin markers themselves gives an exact answer:

const pins = [...symbol.querySelectorAll('.pin-point')].map((pin, i) => {
    const p = new DOMPoint(cx(pin), cy(pin)).matrixTransform(wm);
    return { pinId: pin.dataset.pin ?? String(i), x: p.x, y: p.y };
});
const hit = pins.find(p => Math.hypot(p.x - ep.x, p.y - ep.y) <= TOL);

Recording which pin was hit turns the downstream check from "count ports" into "count distinct connected pins," which is the question an electrical rule check actually asks.

Tradeoffs

The matrix walk costs a DOM traversal per element per query. For a few hundred elements per analysis pass this is nothing. If you run spatial queries per pointer-move frame over thousands of nodes, cache the world matrices and invalidate on transform writes.

Keeping the box fallback is worth it. Imported flattened content has no pin metadata, and for those elements local space already equals document space, so the old proximity test is both correct and the only option available.

The rule to carry away: a spatial query with operands from two coordinate spaces is not a bug you have, it is a bug you are scheduled to have. Project at the boundary, name your canonical space, and stop below the camera.

Read this post in the full Engineering Journal →