The bug class where invisible geometry still counts toward your layout
TLDR
getBBox() returns the union of all of an element's geometry, including subpaths that render nothing. A single leftover vertex parked far from the artwork stretches the box across the gap, so selection handles and the properties readout land on a corner with nothing at it. Measure what draws, not what exists.
The bug class
A layout or hit-testing API reports the extent of an object by unioning its parts. Some of those parts have zero extent and produce no visible output. The API has no reason to exclude them, because "renders nothing" is not the same as "is not there".
You see this anywhere geometry is aggregated: bounding boxes over empty children, scroll extents including zero-size absolutely positioned nodes, a chart axis stretched by a null-valued series point. The result is always the same shape of complaint. The box is right around nothing you can see.
The instance
The user reported a selected wire whose handles rendered in empty canvas, far from the wire, and the properties panel agreeing with them: X: 1010, Y: 984 for an element visibly at the top-left.
Both surfaces read getBBox(), which is why they agreed with each other and were both wrong. The element was an imported path with a trailing artifact:
d = "m 100,120 l 40,0 l 0,-30 l 60,0 m 810,864 l 0,0"
That final m 810,864 l 0,0 is a moveto followed by a zero-length lineto. It draws nothing. No editor renders it, no user can see it, and most SVG cleaners strip it. Ours did not, and getBBox() counts it:
getBBox() equivalent : {x:100, y:90, width:910, height:864}
actual ink : {x:100, y:90, width:100, height:30}
The box is anchored correctly on the artwork and then stretched to swallow a point 900 units away. Handles are drawn at the box corners, so one of them lands at roughly (1010, 954), in empty space, exactly as reported.
Why the API produces it
getBBox() is specified as the bounds of the element's geometry in its own coordinate system. A subpath consisting of a moveto is geometry. It has a position. Nothing in the spec says "and also, ignore the parts that would not be painted", and it could not reasonably say that, because paint depends on stroke, linecap, markers and filters that the geometry query does not consider.
So the API is right and the assumption is wrong. getBBox() answers "where is this element's geometry", and what a selection box needs is "where is this element's ink". Those coincide for everything you draw yourself, which is why it holds up until someone imports a file.
The fix
Read the path data and skip subpaths with no extent:
function subpathExtent(s) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
s.points.forEach(p => {
if (!isFinite(p.x) || !isFinite(p.y)) return;
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
});
return isFinite(minX) ? Math.max(maxX - minX, maxY - minY) : 0;
}
function bbox(d, opts) { const EPS = 1e-6; const subs = parse(d).subpaths; let live = (opts && opts.includeDegenerate) ? subs : subs.filter(s => subpathExtent(s) > EPS); // Everything degenerate: fall back, so a genuinely point-sized // element still resolves to something rather than to nothing. if (!live.length) live = subs; return unionOf(live); }
Two guards carry most of the safety. The all-degenerate fallback means a legitimately point-sized element does not return null and blow up a caller. And at the call site, never let the computed box exceed the browser's:
_tightBBox(el) {
const raw = el.getBBox();
if (el.tagName?.toLowerCase() !== 'path') return raw;
const tight = GxPathGeo.bbox(el.getAttribute('d'));
if (!tight) return raw;
// Never GROW the box: if our reading is larger, the element has
// geometry we did not model (markers, a filter) and getBBox knows more.
if (tight.width > raw.width + 0.5 || tight.height > raw.height + 0.5) return raw;
return tight;
}
That asymmetry is deliberate. We are confident we can find geometry that should be excluded. We are not confident we know everything that should be included, so in that direction we defer to the browser.
Preventing the class
Distinguish "exists" from "is visible" explicitly. When you aggregate geometry, decide which question you are answering. A selection box wants ink. A serializer wants everything. Same data, different filter, and the API only offers one of them.
Test with data you did not generate. Every instance of this bug I have seen appears only on imported content, because your own writer does not emit degenerate output. Keep a fixture file from a real third-party tool.
Watch for the two-surfaces-agree tell. The property panel and the handles both showed the wrong number, which initially read as corroboration. It was not: they shared an input. When two independent-looking surfaces agree on something wrong, check whether they are actually independent before you trust the agreement.
The one-line version: a bounding box should measure what the element draws, and getBBox() measures what it contains.