Spatial Queries Fail Quietly When Geometry Lives in Two Coordinate Spaces
TLDR
Spatial queries (hit-testing, marquee selection, pin snapping, connectivity checks) fail silently when comparing geometry across mixed coordinate spaces. Un-transformed flat elements (wires) exist in global document space, whereas transformed symbols (components inside translated<g> elements) exist in local group space. Calling element.getBBox() returns un-transformed local bounds, causing spatial comparisons against global coordinates to fail. Projecting all geometry into document space via CTM matrix walks solves this permanently.
| Coordinate Space | Source Geometry | getBBox() Coordinates | Spatial Query Compatibility |
|---|---|---|---|
| Local Symbol Space | Transformed SVG <g> elements | Un-transformed local origins | Incompatible with Document Space |
| Document Space | Projected via CTM Matrix Walk | World document coordinates | 100% Compatible Across All Query Engines |
Problem statement: the mixed coordinate space trap
Interactive SVG editors handle two kinds of geometry:
- Flat Geometry: Wires and lines authored directly in document coordinates (e.g.,
<path d="M 530 300 L 770 300" />). - Transformed Geometry: Symbol components authored in local space around $(0,0)$ and placed via container transforms (e.g.,
<g transform="translate(500, 300)">).
getBBox() compares two different coordinate systems.
Technical failure mode: silent query failures
// DEFECTIVE IMPLEMENTATION: Direct getBBox() comparison
const symbolBBox = symbolElement.getBBox(); // Returns local bounds: { x: -32, y: -32, w: 64, h: 64 }
const wireEndpoint = { x: 530, y: 300 }; // Global document coordinate
// Overlap check compares 530 against 32 -> FAILS SILENTLY! if (wireEndpoint.x >= symbolBBox.x && wireEndpoint.x <= symbolBBox.x + symbolBBox.width) { // Never executes! }
The browser returns getBBox() in local element space without applying ancestor transforms.
The query fails silently, reporting every component as "unconnected" despite visible wire connections on screen.
The fix & architecture: canonical matrix walk projection
Project all geometry into a single canonical coordinate space (Document Space) prior to executing spatial evaluations:
// 1. Traverse parent chain to calculate global matrix
function getDocumentMatrix(element, cameraGroup) {
let matrix = new DOMMatrix();
let node = element;
while (node && node !== cameraGroup && node !== element.ownerSVGElement) { const transformList = node.transform?.baseVal; if (transformList?.length) { const localMatrix = transformList.consolidate()?.matrix; if (localMatrix) { matrix = new DOMMatrix([ localMatrix.a, localMatrix.b, localMatrix.c, localMatrix.d, localMatrix.e, localMatrix.f ]).multiply(matrix); } } node = node.parentElement; } return matrix; }
// 2. Project local symbol pins into canonical Document Space function getCanonicalPinCoordinates(symbolElement, cameraGroup) { const worldMatrix = getDocumentMatrix(symbolElement, cameraGroup); const pinPoints = symbolElement.querySelectorAll('.pin-marker');
return Array.from(pinPoints).map((pin, index) => { const localX = parseFloat(pin.getAttribute('cx') || 0); const localY = parseFloat(pin.getAttribute('cy') || 0);
// Transform local point to document space const docPoint = new DOMPoint(localX, localY).matrixTransform(worldMatrix);
return { pinId: pin.dataset.pinId || pin_${index}, x: docPoint.x, y: docPoint.y }; }); }
Rule of thumb: Never compare raw getBBox() values across transformed parent nodes. Project local element coordinates into canonical document space using CTM matrix walks before running spatial queries.