Skip to content

fix: recompute default position when dimension order changes - #1084

Open
AKnassa wants to merge 6 commits into
google:masterfrom
AKnassa:rocky/issue-885-position-out-of-bounds
Open

fix: recompute default position when dimension order changes#1084
AKnassa wants to merge 6 commits into
google:masterfrom
AKnassa:rocky/issue-885-position-out-of-bounds

Conversation

@AKnassa

@AKnassa AKnassa commented Jul 27, 2026

Copy link
Copy Markdown

What this does

When you load a saved view that has no position saved in it, Neuroglancer picks a sensible starting point for you — the middle of the data. This makes sure it works that out fresh for the view you're actually loading, instead of reusing the one it picked for the previous view.

Why

If the new view lists its dimensions in a different order than the old one (say z, x, y instead of x, y, z), the old starting point gets applied to the wrong axes. You end up far outside the data, looking at an empty screen. That's what #885 reports — it shows up the second time you press apply in the JSON state editor, because the first apply leaves a position behind.

What changed

01-master-apply1-loading 02-master-apply1-CORRECT 03-master-apply2-BROKEN-z-out-of-bounds 04-fixed-apply1-loading 05-fixed-apply1-CORRECT 06-fixed-apply2-STILL-CORRECT
  • Neuroglancer now keeps track of which starting points it worked out for you, versus which ones you chose yourself — by saving them in a link, or by panning and zooming.
  • Positions you chose are always kept, exactly as before, including ones deliberately outside the data.
  • Only the automatically-chosen ones get recalculated when a view is reloaded.
  • Added 17 tests: the reported case, plus edge cases for reordering, rank changes, panning, unbounded dimensions, and rounding.

How to see it

Run npm run dev-server, open the viewer, and in the browser console apply the state from #885 twice:

viewer.state.restoreState(state);              // loads correctly
viewer.state.reset(); viewer.state.restoreState(state);   // the second apply

Checked against the dataset in the issue (fafb_v14, bounds z: 7063, x: 248832, y: 134144):

  • Before: position [124416.5, 67072.5, 3531.5]z is 124416.5 against a z limit of 7063. Outside the data, blank panels.
  • After: position [3531.5, 124416.5, 67072.5] — inside the data.

Note for reviewers

This tracks a copy of the inferred coordinates rather than a boolean flag, because several callers move the position by writing into the array returned by position.value and dispatching changed directly, never going through the setter — NavigationState.translateVoxelsRelative and the slice-view zoom-at-mouse handler in src/sliceview/panel.ts. A flag would go stale on pan or zoom and silently discard the move.

There is a plausible alternative I'd be glad to switch to if you prefer it: remember the last valid coordinate space and remap by dimension id across the invalid gap. That would carry a panned position through a reorder rather than re-centring it.

Fixes #885

AKnassa added 4 commits July 26, 2026 23:05
`Position` reused its existing coordinates whenever the previous
coordinate space was invalid and the rank matched.  Those coordinates
may have been inferred from the bounds of a different coordinate space,
so re-applying a state that lists the dimensions in a different order
carried the old inferred midpoint over unchanged, leaving the position
outside the volume bounds.

Track whether the current coordinates were inferred from the bounds
rather than specified explicitly, and only reuse them in the latter
case.  Positions restored from JSON, set explicitly, or copied via
`assign` are unaffected.

Fixes google#885
Extend the regression suite around the default-position inference so the
fix in the previous commit is pinned from more than one angle.

Three of these fail without the fix: re-inference after several
consecutive invalid coordinate spaces, after `snapToVoxel` has rounded an
inferred position, and across `assign` (which must carry inferred-ness
with the coordinates it copies).

The rest lock behaviour that must not change: an explicitly restored
out-of-bounds position stays put, an inferred position does not jump when
the same space returns, dimensions are still matched by id when the
previous space stays valid, unbounded dimensions still infer a finite
coordinate, and voxel-centre rounding is respected per dimension.

Fixes google#885
The previous commit tracked whether the coordinates had been inferred
with a boolean that was cleared by `value`'s setter, `restoreState` and
`reset`.  Several callers do not use any of those: they move the position
by writing into the `Float32Array` returned by the `value` getter and then
dispatching `changed` directly.  `NavigationState.translateVoxelsRelative`
and the slice-view zoom-at-mouse handler both do this, so panning or
zooming left the flag set, and the pan was discarded and replaced with the
centre of the bounds the next time the coordinate space was replaced while
invalid.

Keep a copy of the inferred coordinates instead of a flag, and treat the
position as inferred only while the current coordinates still equal that
copy.  Any move detects itself, whichever path made it, and a position
moved back onto the inferred coordinates is again indistinguishable from
one that was never touched.  The remapping path carries the copy through
the same permutation, since remapping is not a move.

Regression tests cover both directions: panning in place is preserved, and
untouched inferred coordinates are still recomputed.

Fixes google#885
Adds three tests found missing while reviewing the fix:

- the sequence reported in the issue, where resetting the viewer state
  leaves an inferred position behind for the outgoing coordinate space
- an inferred position stays inferred across a dimension-id remap, so it
  is still re-inferred if the space is later replaced while invalid
- an explicit position stays explicit across the same remap

Also makes the two `assign` tests observe the target's own invalid
coordinate space before the new one arrives.  The second was previously
a no-op: `assign` copies the source's valid space, so re-assigning the
invalid space the target already held dispatched nothing, and the test
crossed no transition at all.
@AKnassa
AKnassa marked this pull request as ready for review July 28, 2026 05:20
Comment thread src/navigation_state.ts Outdated
// several callers, such as `NavigationState.translateVoxelsRelative`, move
// the position by writing into the array returned by `value` instead of going
// through the setter. Comparing against the copy detects those moves as
// well.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might be best to update those usages because that still opens up the case for one of those callers to navigate away and then back to the same value as inferredCoordinates_ and have it treated as coordinatesAreInferred.

Whereas in your setter, you purposely clear out inferredCoordinates_. However maybe that usage isn't relevant.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, this turned out to be relevant. stepping rounds to the voxel centre, so an arrow key away and back lands exactly on inferredCoordinates_ and got treated as inferred again. I updated those usages: the in-place movers now report via markMoved (only when a coordinate actually changed, so no-op writes still re-infer), and the two channel widgets just go through the setter now. kept the comparison as a fallback for any caller that doesn't report. added tests driving each caller, including the away-and-back cases.

AKnassa added 2 commits July 30, 2026 00:39
Callers that move the position by writing into the array returned by
`Position.value` now report the move via `markMoved`, so navigating away
and back onto exactly the inferred coordinates no longer counts as
inferred.  Only writes that actually change a coordinate are reported,
so no-op interactions still allow re-inference.

The two channel position widgets assign through the setter instead of
writing in place, and syncing an offset-linked position no longer
allocates per update.
Drives every caller that bypasses the position setter: keyboard steps
(including ones the bounds clamp), panning, rotation about a fixed
point, updateDisplayPosition, playback, drag-and-drop through the real
drop handler, and layer global/local positions via `setLayerPosition`.
Also checks repeated away-and-back cycles, no-op writes that must not
count as moves, offset-linked syncs, and a rank-8 space stepped ten
thousand times.
@AKnassa
AKnassa requested a review from chrisj July 30, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Position can be defaulted to out of bounds location on setting a state with no position

2 participants