Build the pick-result callback. Dependencies are captured at call
time — fine because initPicking reassigns the loader / overlay
references before constructing the PickingSystem, and each
dataset reload reruns initPicking end-to-end (disposing the old
picking system and its captured closure with it).
Behavior contract:
Two node paths, deliberately split (#1415). The reported path —
the selection's nodeName and the overlay's title — is the
outermost kind=partition wrapper when the hit sits under one,
mirroring how the layers panel treats a partition wrapper as the
user-facing layer. The queried path — what the label / key / image
loaders are handed — is the hit leaf scene node,
result.mainNode.name, which is the CSR owner for a flat node and
for a part_<i> of a partition. Using the wrapper for the lookup fails
twice over: it is a bare group with no label/key CSR arrays (they are
written per part_<i>), and result.elementId is an index in the leaf's
own element space, meaningless against a whole-node array. Before the
split, every hover on a partitioned layer resolved to an empty tooltip,
silently — LabelLoader demotes the missing array to an info log and
caches that the node lacks the channel. Of the three lookups only the
label/key getLabel calls are reachable under a partition today (all four
adders refuse image_labels alongside partition=, so no part_<i> ever
owns an image CSR);
getImageUrl moves with them for consistency, not because it is broken
today.
The selection event carries both paths so the split is resolvable
from outside: nodeName is reportPath (display) and
hitNodeName is lookupPath (the node elementIndex is local to,
and what an embedder should index against).
Two known limits survive this fix, both outside the handler:
(i) an additive ladder carries ONE union CSR per present labels/keys
channel on its parent node (#1422), spanning the levels in additive_<i>
order — the same node lookupPath names, and the same space the
progressive loader produces when it concatenates the committed levels, so
the lookup is correct — for POINTS also under slicing, since the loader now
composes each level's slot → on-disk map into that union space, offsetting
level i by the preceding levels' on-disk n_points (#1439). Not for
LINES: its raw slot is a per-segment one while the union CSR is
per-vertex (#1424), so a laddered lines node with a string channel is
wrong at the granularity, not merely at an offset, whatever the slicing —
and nothing composes a lines ladder's LEVELS either; gsplat ladders carry
no labels/keys at all; and
(ii) result.elementId is only sometimes the on-disk CSR index. It
arrives already resolved wherever the node can resolve one — Points,
GSplats and Lines all do, for a node declaring has_labels /
has_image_labels / has_keys, through a published slot → on-disk map or
trivially where the identity already holds and no map is published,
and Mesh needs none because its gl_VertexID already IS the on-disk
vertex ordinal (see
rendering/picking/picking-system/element-id-map.ts, which does the
translation at the single PickResult construction site). For a
LINES node the resolved value is the picked segment's start
vertex row in the on-disk (spatially sorted) VERTEX ordering, not a
segment row: line string channels are per-vertex, and a segment carries a
single flat pick id, so exactly one of its two endpoints can be
reported and by convention it is the start (#1424). That resolution
is published for a FLAT lines node only, so any lines node without
it — one with no string channel, and equally a LADDERED one with a
string channel, whose per-level maps a lines ladder's concat still
drops (limit (i), where only Points composes them) — still reports the raw
visible-segment slot, which is neither an on-disk row nor even the
right granularity for a per-vertex CSR. A Points or GSplats node with
no per-element string channel likewise keeps the raw storage slot — no
CSR to miss, but an embedder reading SelectionPayload.elementIndex
there is reading a slot.
null result → clear hover (updateHoverContent(null)); no loader calls.
Non-null result → fetch label + key + image URL in parallel; emit a
payload only when at least one is truthy. An empty string
counts as no content (matches LabelLoader.getLabel which
already returns null for empty labels, but the falsy gate
here is the second line of defense).
Any fetch rejects → log a warning and clear hover. Errors must
not kill the hover loop.
Ordering: the string/image fetch is async, so a slow fetch from an
older invocation could resolve AFTER a newer one (e.g. an uncached
image while the cursor moves on) and re-show a stale tooltip over
fresher state. A monotonic latest token, captured per call, gates
the post-await emit: a superseded invocation drops its result. A
null (hide) call applies immediately and bumps the token, so it
also cancels any in-flight content fetch.
Build the pick-result callback. Dependencies are captured at call time — fine because
initPickingreassigns the loader / overlay references before constructing thePickingSystem, and each dataset reload rerunsinitPickingend-to-end (disposing the old picking system and its captured closure with it).Behavior contract:
Two node paths, deliberately split (#1415). The reported path — the selection's
nodeNameand the overlay's title — is the outermostkind=partitionwrapper when the hit sits under one, mirroring how the layers panel treats a partition wrapper as the user-facing layer. The queried path — what the label / key / image loaders are handed — is the hit leaf scene node,result.mainNode.name, which is the CSR owner for a flat node and for apart_<i>of a partition. Using the wrapper for the lookup fails twice over: it is a bare group with no label/key CSR arrays (they are written perpart_<i>), andresult.elementIdis an index in the leaf's own element space, meaningless against a whole-node array. Before the split, every hover on a partitioned layer resolved to an empty tooltip, silently —LabelLoaderdemotes the missing array to an info log and caches that the node lacks the channel. Of the three lookups only the label/keygetLabelcalls are reachable under a partition today (all four adders refuseimage_labelsalongsidepartition=, so nopart_<i>ever owns an image CSR);getImageUrlmoves with them for consistency, not because it is broken today.The selection event carries both paths so the split is resolvable from outside:
nodeNameisreportPath(display) andhitNodeNameislookupPath(the nodeelementIndexis local to, and what an embedder should index against).Two known limits survive this fix, both outside the handler: (i) an additive ladder carries ONE union CSR per present labels/keys channel on its parent node (#1422), spanning the levels in
additive_<i>order — the same nodelookupPathnames, and the same space the progressive loader produces when it concatenates the committed levels, so the lookup is correct — for POINTS also under slicing, since the loader now composes each level's slot → on-disk map into that union space, offsetting leveliby the preceding levels' on-diskn_points(#1439). Not for LINES: its raw slot is a per-segment one while the union CSR is per-vertex (#1424), so a laddered lines node with a string channel is wrong at the granularity, not merely at an offset, whatever the slicing — and nothing composes a lines ladder's LEVELS either; gsplat ladders carry no labels/keys at all; and (ii)result.elementIdis only sometimes the on-disk CSR index. It arrives already resolved wherever the node can resolve one — Points, GSplats and Lines all do, for a node declaringhas_labels/has_image_labels/has_keys, through a published slot → on-disk map or trivially where the identity already holds and no map is published, and Mesh needs none because itsgl_VertexIDalready IS the on-disk vertex ordinal (seerendering/picking/picking-system/element-id-map.ts, which does the translation at the singlePickResultconstruction site). For a LINES node the resolved value is the picked segment's start vertex row in the on-disk (spatially sorted) VERTEX ordering, not a segment row: line string channels are per-vertex, and a segment carries a singleflatpick id, so exactly one of its two endpoints can be reported and by convention it is the start (#1424). That resolution is published for a FLAT lines node only, so any lines node without it — one with no string channel, and equally a LADDERED one with a string channel, whose per-level maps a lines ladder's concat still drops (limit (i), where only Points composes them) — still reports the raw visible-segment slot, which is neither an on-disk row nor even the right granularity for a per-vertex CSR. A Points or GSplats node with no per-element string channel likewise keeps the raw storage slot — no CSR to miss, but an embedder readingSelectionPayload.elementIndexthere is reading a slot.nullresult → clear hover (updateHoverContent(null)); no loader calls.Non-null result → fetch label + key + image URL in parallel; emit a payload only when at least one is truthy. An empty string counts as no content (matches
LabelLoader.getLabelwhich already returnsnullfor empty labels, but the falsy gate here is the second line of defense).Any fetch rejects → log a warning and clear hover. Errors must not kill the hover loop.
Ordering: the string/image fetch is async, so a slow fetch from an older invocation could resolve AFTER a newer one (e.g. an uncached image while the cursor moves on) and re-show a stale tooltip over fresher state. A monotonic
latesttoken, captured per call, gates the post-awaitemit: a superseded invocation drops its result. Anull(hide) call applies immediately and bumps the token, so it also cancels any in-flight content fetch.