From 98b4e7f5c4b513b86a2134e66e018f281eeab06f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 17:35:04 -0400 Subject: [PATCH 1/7] fix(viewer): align mounted semantic slots before route publication --- openhcs/runtime/napari_streaming_handlers.py | 59 ++++++- openhcs/runtime/napari_viewer_server.py | 159 ++++++++++++++++--- 2 files changed, 196 insertions(+), 22 deletions(-) diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index aee44ed577..da79ed1677 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -115,6 +115,8 @@ class VisualMetadataField(str, Enum): class NapariLayerHandle(ABC): """Nominal marker for concrete layer objects returned by a Napari viewer.""" + visible: bool + class NapariHighlightEmitterABC(ABC): """Native Napari highlight event used by selectable layers.""" @@ -147,7 +149,6 @@ class NapariShapesLayerHandle(NapariLayerHandle): face_color_mode: str edge_color_cycle: Sequence[tuple[float, float, float, float]] face_color_cycle: Sequence[tuple[float, float, float, float]] - visible: bool events: NapariSelectableLayerEventsABC @abstractmethod @@ -492,6 +493,23 @@ class NapariStreamLayerItem: image_metadata: ImagePayloadMetadata plane_component_domain: ViewerComponentValueDomainPayload + ELEMENT_IDENTITY_FEATURE: ClassVar[str] = "openhcs_source_element" + + def element_identity(self, member_index: int, coordinate_index: int = 0) -> str: + """Identify a source member independently of projected axes or table order. + + Native features carry this derived, opaque key; the source item remains + its owner. Member positions refer to this unchanged streamed payload, + not to rows in a subsequently assembled native layer. + """ + return repr(( + self.producer, + tuple(sorted(self.address.components.items())), + self.address.path, + member_index, + coordinate_index, + )) + class NapariImagePayloadAxisLabelPolicy: """Labels payload-local image stack axes before the spatial y/x axes.""" @@ -1396,6 +1414,30 @@ def viewer_dimension_indices(self, viewer_ndim: int) -> tuple[int, ...]: raise ValueError("Route dimension rank exceeds native viewer rank.") return tuple(range(offset, viewer_ndim)) + @contextmanager + def preserve_native_axes(self, viewer, replacement: "NapariAxisPresentation"): + """Carry actual native world positions/order through semantic slot insertion. + + This is a transient presentation snapshot, not a second component domain. + Values come from native Dims and names from the original presentations. + Restore before selectable handlers check geometry on the current slice. + """ + dimensions = self.viewer_dimension_indices(viewer.dims.ndim) + names = dict(zip(dimensions, self.axis_labels, strict=True)) + points = {name: viewer.dims.point[axis] for axis, name in names.items()} + order = tuple(names[axis] for axis in viewer.dims.order if axis in names) + yield + target_dimensions = replacement.viewer_dimension_indices(viewer.dims.ndim) + target_axes = dict(zip(replacement.axis_labels, target_dimensions, strict=True)) + point = list(viewer.dims.point) + for name, value in points.items(): + point[target_axes[name]] = value + viewer.dims.point = tuple(point) + retained_order = tuple(target_axes[name] for name in order) + viewer.dims.order = tuple( + axis for axis in viewer.dims.order if axis not in retained_order + ) + retained_order + def display_order( self, display_axes: tuple[str, str], current_order: tuple[int, ...] ) -> tuple[int, ...]: @@ -1637,6 +1679,16 @@ def mounted_dimension_states( if layer_key in self.layers ) + def shared_display_layout( + self, layout: ViewerComponentLayout + ) -> ViewerComponentLayout: + """Derive native slots from declarations of actually mounted routes.""" + return layout.with_shared_stack_axes(tuple( + mounted_layout + for _route, state in self.mounted_dimension_states() + for mounted_layout in state.display_layouts + )) + def axis_origins_for(self, axis_labels: tuple[str, ...]) -> tuple[int, ...]: """Return normalized-viewer origins derived from mounted route offsets.""" compatible_states = tuple( @@ -1979,6 +2031,7 @@ def append( *, label: int, path: str, + element_identity: str, ) -> None: """Append one metadata row while preserving first-seen column order.""" @@ -1993,6 +2046,7 @@ def append( self._set_last(str(name), NapariShapeLayerPayload._feature_value(value)) self._set_last(VisualMetadataField.LABEL.value, label) self._set_last(ViewerWireField.PATH.value, path) + self._set_last(NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE, element_identity) self.row_count += 1 def _set_last(self, name: str, value: object) -> None: @@ -2145,7 +2199,7 @@ def build( raise TypeError( "Napari SHAPES payload data must be a sequence of shape mappings." ) - for shape_dict in item.data: + for member_index, shape_dict in enumerate(item.data): if not isinstance(shape_dict, Mapping): raise TypeError( "Napari SHAPES payload entries must be shape mappings." @@ -2198,6 +2252,7 @@ def build( metadata, label=label_allocator.label_for(shape_dict), path=item.address.path, + element_identity=item.element_identity(member_index), ) shape_data.append(coordinates) diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index 94becdb54b..ac5a14e848 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -1163,7 +1163,9 @@ def _build_nd_points( Tuple of (all_points_nd, all_properties) """ all_points_nd = [] - all_properties = {"label": [], "component": []} + all_properties = { + "label": [], "component": [], NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE: [], + } point_metadata: list[Mapping[str, NapariWireValue]] = [] for item in layer_items: @@ -1175,7 +1177,7 @@ def _build_nd_points( ) ) - for shape_dict in points_data: + for member_index, shape_dict in enumerate(points_data): shape_payload = ShapePayload(shape_dict) if shape_payload.shape_type != "points": continue @@ -1194,7 +1196,7 @@ def _build_nd_points( "Fractional-Z point ROI requires a projected z_index axis." ) from exc - for coord in coordinates: + for coordinate_index, coord in enumerate(coordinates): point_dims = prepend_dims.copy() if z_axis_index is not None and fractional_z is not None: point_dims[z_axis_index] += fractional_z.value @@ -1210,11 +1212,15 @@ def _build_nd_points( point_metadata.append( ROIArchiveSourceMetadata.feature_metadata(metadata.metadata) ) + all_properties[NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE].append( + item.element_identity(member_index, coordinate_index) + ) excluded = { "label", "component", ROIFractionalZ.FIELD, + NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE, } feature_fields = { key @@ -1549,6 +1555,9 @@ def _route_matches_viewer_context( ) -> bool: if route_key is None: return False + route_state = self.server.layer_route_state + if route_key not in route_state.layers or not route_state.layer(route_key).visible: + return False state = self.server.layer_route_state.dimension_state_for(route_key) axis_labels = state.axis_labels viewer_axis_origins = self.server.layer_route_state.axis_origins_for( @@ -1746,6 +1755,7 @@ def create_or_update_layer( """Create or replace the concrete Napari layer for this display request.""" route_key = self.presentation.route_key layer_route_state = self.pipeline.server.layer_route_state + self.reconcile_peers() layer = _NAPARI_LAYER_UPDATES.create_or_update( layer_kind=layer_kind, viewer=self.pipeline.server.viewer, @@ -1761,6 +1771,7 @@ def create_or_update_layer( def mount_layer(self, layer: NapariLayerHandle) -> None: """Publish only a complete native candidate through the route owner.""" server = self.pipeline.server + self.reconcile_peers() _NAPARI_LAYER_UPDATES.mount( viewer=server.viewer, layers=server.layer_route_state.layers, @@ -1783,11 +1794,16 @@ def publish(self) -> None: self.presentation, display_config=self.display_config, ) - self.reconcile_peers() def reconcile_peers(self) -> None: self.pipeline.reconcile_mounted_axis_projections( updated_route_key=self.presentation.route_key, + display_layout=self.presentation.layout, + viewer_component_values=self.pipeline.server.component_values.shared_values_for( + self.presentation.display_axis_components, + replacement_route=self.presentation.route_key, + additional_component_values=self.presentation.component_values(), + ), ) @@ -1841,8 +1857,14 @@ def preserve_native_presentation(self, request: NapariLayerDisplayRequest): layer.visible, layer.opacity, layer.blending = visible, opacity, blending def rematerialize(self, request: NapariLayerDisplayRequest) -> None: + presentation = request.pipeline.server.layer_route_state.dimension_state_for( + request.presentation.route_key + ).presentation with self.preserve_native_presentation(request): - self.handle(request) + with presentation.preserve_native_axes( + request.pipeline.server.viewer, request.presentation, + ): + self.handle(request) def geometric_component_values( self, @@ -1879,6 +1901,18 @@ def preserve_native_presentation(self, request: NapariLayerDisplayRequest): layer.colormap, layer.interpolation2d = colormap, interpolation +class NapariSelectablePresentationRetention: + """Independent feature-row selection capability, composing through the MRO.""" + + @contextmanager + def preserve_native_presentation(self, request: NapariLayerDisplayRequest): + with request.pipeline.server.result_selection_controller.preserve_selection( + request.presentation.route_key, + ): + with super().preserve_native_presentation(request): + yield + + @dataclass(frozen=True, slots=True) class NapariImageLayerDisplayHandler( NapariImagePresentationRetention, NapariLayerDisplayHandler, @@ -2069,7 +2103,9 @@ def advance(self) -> bool: @dataclass(frozen=True, slots=True) -class NapariShapesLayerDisplayHandler(NapariLayerDisplayHandler): +class NapariShapesLayerDisplayHandler( + NapariSelectablePresentationRetention, NapariLayerDisplayHandler, +): """Build or update a native N-D Napari Shapes layer from routed ROIs.""" streaming_data_type: ClassVar[StreamingDataType] = StreamingDataType.SHAPES @@ -2140,7 +2176,9 @@ def handle(self, request: NapariLayerDisplayRequest) -> None: @dataclass(frozen=True, slots=True) -class NapariPointsLayerDisplayHandler(NapariLayerDisplayHandler): +class NapariPointsLayerDisplayHandler( + NapariSelectablePresentationRetention, NapariLayerDisplayHandler, +): """Build or update a Napari points layer from routed point payloads.""" streaming_data_type: ClassVar[StreamingDataType] = StreamingDataType.POINTS @@ -2301,6 +2339,7 @@ def reconcile_mounted_axis_projections( *, updated_route_key: str | None = None, viewer_component_values: ComponentValues | None = None, + display_layout: ViewerComponentLayout | None = None, apply: bool = True, rematerialize: bool = False, ) -> None: @@ -2308,10 +2347,12 @@ def reconcile_mounted_axis_projections( Independently streamed routes can declare different subsets of the same component axis. A newly observed value may therefore change the shared - coordinate assigned to an already mounted singleton route. Shape-neutral - changes are applied directly to that route's native transform and semantic - presentation. A change that would alter stored array extents fails during - dispatch instead of leaving silently misaligned layers. + coordinate assigned to an already mounted singleton route. Every route + also shares the declaration-owned native axis slots. Adding stack slots + rematerializes through the original display handler without changing + grouping or source coordinates. Shape-neutral domain changes update the + transform directly; other extent changes still require explicit + rematerialization instead of leaving silently misaligned layers. """ for ( @@ -2323,7 +2364,12 @@ def reconcile_mounted_axis_projections( items = self.server.component_groups.existing_items_for(route_key) if not items: continue - axis_projection_semantics = state.presentation.axis_projection_semantics() + layout = self.server.layer_route_state.shared_display_layout( + state.presentation.layout + ) + if display_layout is not None: + layout = layout.with_shared_stack_axes((display_layout,)) + axis_projection_semantics = state.presentation.for_display_layout(layout) aggregate_axis_bindings = NapariAggregateAxisBindingAuthority.bindings( items, axis_projection_semantics, @@ -2336,12 +2382,19 @@ def reconcile_mounted_axis_projections( publish=apply, viewer_component_values=viewer_component_values, ) - presentation = replace(state.presentation, projection=projection) + presentation = replace(state.presentation, layout=layout, projection=projection) + slots_changed = ( + presentation.display_axis_components + != state.presentation.display_axis_components + ) if ( - presentation.aligned_component_shape() + slots_changed + or presentation.aligned_component_shape() != state.presentation.aligned_component_shape() ): - if rematerialize: + if slots_changed or rematerialize: + if not apply: + continue NapariLayerDisplayHandler.for_data_type( items[0].address.stream_layer_data_type ).rematerialize( @@ -2593,11 +2646,8 @@ def display_layer_batch( ) display_layout = ViewerObjectDisplayConfigInput( display_payload.display_config - ).layout().with_shared_stack_axes(tuple( - layout - for _route, state in self.server.layer_route_state.mounted_dimension_states() - for layout in state.display_layouts - )) + ).layout() + display_layout = self.server.layer_route_state.shared_display_layout(display_layout) projection_semantics = display_payload.for_display_layout(display_layout) preview_values = self.server.component_values.shared_values_for( display_layout.components_for_mode(ViewerComponentMode.STACK), @@ -2607,6 +2657,7 @@ def display_layer_batch( self.reconcile_mounted_axis_projections( updated_route_key=layer_key, viewer_component_values=preview_values, + display_layout=display_layout, apply=False, ) axis_projection = self.display_axis_projection( @@ -3298,6 +3349,74 @@ def __init__(self, server: "NapariViewerServer") -> None: self.ensure_default_highlight_thickness() self.ensure_default_highlight_color() + @contextmanager + def preserve_selection( + self, + route_key: str, + ): + """Retain source members across remounts without replaying navigation. + + Selection is translated through native features derived by the original + source payload builder. There is no persistent row roster, coordinate + matching, or assumption that an old table index still names that member. + """ + layer = self.server.layer_route_state.layer(route_key) + state = NapariResultElementSelectionAuthority.state(layer) + identities = self._element_identities(layer, state) + selected = frozenset(identities[index] for index in state.selected_data_indices) + binding = self._group_indices.get(layer) + bound = self.is_bound_result_layer(layer) + synchronizing = self._synchronizing_group_selection + self._synchronizing_group_selection = True + self._pending_generation += 1 + try: + yield + target = self.server.layer_route_state.layer(route_key) + target_state = NapariResultElementSelectionAuthority.state(target) + target_identities = self._element_identities(target, target_state) + indices = tuple( + index for index, identity in enumerate(target_identities) + if identity in selected + ) + displayed = NapariResultElementSelectionAuthority.displayed_indices( + target, indices, + ) + if displayed != indices: + raise ValueError( + "Rematerialized selected source members have no displayed geometry " + "on the current slice." + ) + if bound: + if layer is not target: + cast(NapariShapesLayerHandle, layer).events.highlight.disconnect( + self._callbacks.pop(layer) + ) + self._observed_indices.pop(layer, None) + self._group_indices.pop(layer, None) + self.bind(target, binding.binding if binding is not None else None) + observed = NapariResultElementSelectionAuthority.select_indices(target, displayed) + if bound: + self._observed_indices[target] = observed.selected_data_indices + finally: + self._synchronizing_group_selection = synchronizing + self._notify_selection_observers() + + @staticmethod + def _element_identities( + layer: NapariLayerHandle, + state: NapariResultElementSelectionState, + ) -> tuple[object, ...]: + if state.feature_row_count == 0: + return () + identities = NapariResultSelectionGroupAuthority.feature_values( + layer, NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE, + ) + if identities is None or len(identities) != state.feature_row_count: + raise ValueError("Selectable stream rows require source element identities.") + if len(set(identities)) != len(identities): + raise ValueError("Selectable stream source element identities must be unique.") + return identities + def select( self, layer: NapariLayerHandle, From 51a7302f41ebcc383096e58eff342fa812545526 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 17:54:07 -0400 Subject: [PATCH 2/7] fix(viewer): reconcile all peers against one native frame snapshot --- openhcs/runtime/napari_streaming_handlers.py | 38 +++++-- openhcs/runtime/napari_viewer_server.py | 113 +++++++++++++------ tests/unit/test_napari_reset_transitions.py | 104 ++++++++++++++++- tests/unit/test_napari_streaming_handlers.py | 86 +++++++------- 4 files changed, 253 insertions(+), 88 deletions(-) diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index da79ed1677..06be43db51 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -1415,27 +1415,47 @@ def viewer_dimension_indices(self, viewer_ndim: int) -> tuple[int, ...]: return tuple(range(offset, viewer_ndim)) @contextmanager - def preserve_native_axes(self, viewer, replacement: "NapariAxisPresentation"): + def preserve_native_axes( + self, dims, replacement: "NapariAxisPresentation", + items: Sequence[NapariStreamLayerItem], + ): """Carry actual native world positions/order through semantic slot insertion. This is a transient presentation snapshot, not a second component domain. Values come from native Dims and names from the original presentations. Restore before selectable handlers check geometry on the current slice. """ - dimensions = self.viewer_dimension_indices(viewer.dims.ndim) + dimensions = self.viewer_dimension_indices(dims.ndim) names = dict(zip(dimensions, self.axis_labels, strict=True)) - points = {name: viewer.dims.point[axis] for axis, name in names.items()} - order = tuple(names[axis] for axis in viewer.dims.order if axis in names) + points = {name: dims.point[axis] for axis, name in names.items()} + order = tuple(names[axis] for axis in dims.order if axis in names) yield - target_dimensions = replacement.viewer_dimension_indices(viewer.dims.ndim) + target_dimensions = replacement.viewer_dimension_indices(dims.ndim) target_axes = dict(zip(replacement.axis_labels, target_dimensions, strict=True)) - point = list(viewer.dims.point) + point = list(dims.point) for name, value in points.items(): point[target_axes[name]] = value - viewer.dims.point = tuple(point) + transform = replacement.spatial_layer_kwargs(items, replacement.payload_axis_labels) + for name in replacement.display_axis_components: + if name in points: + continue + routed_values = replacement.projection.routed_component_values[name] + if len(routed_values) != 1: + raise ValueError( + f"New native slot {name!r} requires an unambiguous original route value." + ) + local_index = ( + replacement.projection.component_values[name].index(routed_values[0]) + if name in replacement.projection.component_values else 0 + ) + axis = replacement.axis_labels.index(name) + point[target_axes[name]] = ( + transform["translate"][axis] + local_index * transform["scale"][axis] + ) + dims.point = tuple(point) retained_order = tuple(target_axes[name] for name in order) - viewer.dims.order = tuple( - axis for axis in viewer.dims.order if axis not in retained_order + dims.order = tuple( + axis for axis in dims.order if axis not in retained_order ) + retained_order def display_order( diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index ac5a14e848..c904461d32 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -1811,6 +1811,33 @@ def reconcile_peers(self) -> None: class NapariRematerializationRequest(NapariLayerDisplayRequest): """The original reconciliation traversal already owns every survivor.""" + original_presentation: NapariAxisPresentation + native_frame: napari.components.Dims + + @property + def slots_changed(self) -> bool: + return ( + self.presentation.display_axis_components + != self.original_presentation.display_axis_components + ) + + @property + def requires_rematerialization(self) -> bool: + return ( + self.slots_changed + or self.presentation.aligned_component_shape() + != self.original_presentation.aligned_component_shape() + ) + + def restore_native_frame(self) -> None: + """Apply the one batch snapshot before native selection eligibility.""" + dims = self.pipeline.server.viewer.dims + offset = dims.ndim - self.native_frame.ndim + if offset < 0: + raise ValueError("Reconciled native frame exceeds the mounted viewer rank.") + dims.point = (*dims.point[:offset], *self.native_frame.point) + dims.order = (*range(offset), *(axis + offset for axis in self.native_frame.order)) + def reconcile_peers(self) -> None: pass @@ -1856,15 +1883,10 @@ def preserve_native_presentation(self, request: NapariLayerDisplayRequest): layer = state.layer(route) layer.visible, layer.opacity, layer.blending = visible, opacity, blending - def rematerialize(self, request: NapariLayerDisplayRequest) -> None: - presentation = request.pipeline.server.layer_route_state.dimension_state_for( - request.presentation.route_key - ).presentation + def rematerialize(self, request: NapariRematerializationRequest) -> None: with self.preserve_native_presentation(request): - with presentation.preserve_native_axes( - request.pipeline.server.viewer, request.presentation, - ): - self.handle(request) + self.handle(request) + request.restore_native_frame() def geometric_component_values( self, @@ -2355,6 +2377,7 @@ def reconcile_mounted_axis_projections( rematerialization instead of leaving silently misaligned layers. """ + requests: list[NapariRematerializationRequest] = [] for ( route_key, state, @@ -2379,46 +2402,64 @@ def reconcile_mounted_axis_projections( axis_projection_semantics, items, aggregate_axis_bindings, - publish=apply, + publish=False, viewer_component_values=viewer_component_values, ) presentation = replace(state.presentation, layout=layout, projection=projection) - slots_changed = ( - presentation.display_axis_components - != state.presentation.display_axis_components + request = NapariRematerializationRequest( + pipeline=self, + items=items, + presentation=presentation, + display_config=state.display_config, + original_presentation=state.presentation, + native_frame=self.server.viewer.dims, ) if ( - slots_changed - or presentation.aligned_component_shape() - != state.presentation.aligned_component_shape() + request.requires_rematerialization + and not request.slots_changed + and not rematerialize ): - if slots_changed or rematerialize: - if not apply: - continue - NapariLayerDisplayHandler.for_data_type( - items[0].address.stream_layer_data_type - ).rematerialize( - NapariRematerializationRequest( - pipeline=self, - items=items, - presentation=presentation, - display_config=state.display_config, - ) - ) - continue raise ValueError( "Napari shared semantic axis expansion requires route " f"{route_key!r} to be rematerialized; old component shape=" f"{state.presentation.aligned_component_shape()!r}, new=" f"{presentation.aligned_component_shape()!r}." ) - if not apply: - continue - layer = self.server.layer_route_state.layer(route_key) - layer.translate = presentation.spatial_layer_kwargs( - items, presentation.payload_axis_labels - )["translate"] - self.dimension_label_store.apply(presentation) + requests.append(request) + + if not apply or not requests: + return + if any(request.requires_rematerialization for request in requests): + # Capture once, before any peer changes native rank. The original + # label/selection resolver owns which source frame is current. + current_route = self.dimension_label_overlay.route_resolver.resolve().route_key + basis = next( + (request for request in requests if request.presentation.route_key == current_route), + requests[0], + ) + native_frame = self.server.viewer.dims.copy() + if basis.slots_changed: + with basis.original_presentation.preserve_native_axes( + native_frame, basis.presentation, basis.items, + ): + native_frame.ndim = max(native_frame.ndim, len(basis.presentation.axis_labels)) + offset = native_frame.ndim - len(basis.presentation.axis_labels) + native_frame.axis_labels = ( + *native_frame.axis_labels[:offset], *basis.presentation.axis_labels, + ) + requests = [replace(request, native_frame=native_frame) for request in requests] + + for request in requests: + if request.requires_rematerialization: + NapariLayerDisplayHandler.for_data_type( + request.items[0].address.stream_layer_data_type + ).rematerialize(request) + else: + layer = self.server.layer_route_state.layer(request.presentation.route_key) + layer.translate = request.presentation.spatial_layer_kwargs( + request.items, request.presentation.payload_axis_labels, + )["translate"] + request.publish() def schedule_layer_update( self, diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index 17903916c0..3fe22dc94a 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -39,10 +39,13 @@ NapariImageLayerDisplayHandler, NapariImagePresentationRetention, NapariShapesLayerDisplayHandler, + NapariPointsLayerDisplayHandler, + NapariSelectablePresentationRetention, NapariResultSelectionController, NapariStreamLayerContext, NapariViewerServer, ) +from openhcs.runtime.napari_streaming_handlers import NapariStreamLayerItem from openhcs.agent.dto.execution import ExecutionConnectionSpec from openhcs.agent.dto.viewer import ViewerWindowLayerRetirementRequest from openhcs.agent.services.viewer_window_service import ( @@ -98,8 +101,10 @@ def enqueue( data_type=StreamingDataType.IMAGE, spacing=0.65, domain=None, + display_config=None, + channel=1, ): - config = NapariDisplayConfig( + config = display_config or NapariDisplayConfig( well_mode=NapariDimensionMode.STACK, site_mode=NapariDimensionMode.LAYER, channel_mode=NapariDimensionMode.LAYER, @@ -115,7 +120,7 @@ def enqueue( } ), ViewerComponentValueDomainPayload.from_ordered_wire_mapping( - {"well": domain or [well]}, context="synthetic transition" + {"well": domain or [well], "channel": [channel]}, context="synthetic transition" ), ) context = NapariStreamLayerContext( @@ -129,7 +134,7 @@ def enqueue( pipeline_position=0, ), address=NapariStreamLayerAddress( - {"well": well, "site": 1, "channel": 1, "z_index": 1, "timepoint": 1}, + {"well": well, "site": 1, "channel": channel, "z_index": 1, "timepoint": 1}, f"{well}.tif", data_type, ), @@ -173,6 +178,99 @@ def callback(): raise errors[0] +@pytest.mark.parametrize("replace_layers", [False, True]) +@pytest.mark.parametrize("result_first", [False, True]) +@pytest.mark.parametrize("data_type", [StreamingDataType.SHAPES, StreamingDataType.POINTS]) +def test_shared_slot_batch_aligns_two_manual_channels_and_wells( + receiver, result_first, data_type, replace_layers, +): + """Four earlier 5D raw routes promote as one nonrecursive native batch.""" + from openhcs.runtime.napari_viewer_server import NapariNavigationControlMessageAction + from openhcs.runtime.viewer_controls import ViewerNavigationControlOptions + + receiver.replace_layers = replace_layers + wells = [f"{row}{column:02}" for row in "ABCDEFGH" for column in range(1, 13)] + raw_config = NapariDisplayConfig( + channel_mode=NapariDimensionMode.LAYER, well_mode=NapariDimensionMode.LAYER, + ) + result_config = NapariDisplayConfig() + raw_routes = {} + + def raw(): + for well in ("A01", "D06"): + for channel in (1, 2): + value = 10 * wells.index(well) + channel + route, update = enqueue( + receiver, np.full((4, 4), value, dtype=np.uint16), well=well, + channel=channel, producer="raw", domain=wells, display_config=raw_config, + ) + advance_in_qt(receiver, route, update) + layer = receiver.layer_route_state.layer(route) + layer.contrast_limits, layer.gamma, layer.opacity = (0, 500), 0.8, 0.4 + raw_routes[well, channel] = route + + def result(): + for well in ("A01", "D06"): + for channel in (1, 2): + coordinates = [[1, 1]] if data_type is StreamingDataType.POINTS else [[1, 1], [2, 2]] + route, update = enqueue( + receiver, [{"type": "points" if data_type is StreamingDataType.POINTS else "path", + "coordinates": coordinates, "metadata": {"label": 10 * wells.index(well) + channel}}], + well=well, channel=channel, producer="result", data_type=data_type, + domain=wells, display_config=result_config, + ) + advance_in_qt(receiver, route, update) + return route + + if result_first: + result_route = result() + native = receiver.layer_route_state.layer(result_route) + prepared = NapariNavigationControlMessageAction().prepare( + receiver, ViewerNavigationControlOptions(route_key=result_route, data_index=0), + ) + NapariNavigationControlMessageAction().apply_prepared(receiver, prepared) + identities = set(native.features.iloc[list(native.selected_data)][NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE]) + raw() + current = receiver.layer_route_state.layer(result_route) + assert set(current.features.iloc[list(current.selected_data)][NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE]) == identities + else: + raw() + assert {receiver.layer_route_state.layer(route).ndim for route in raw_routes.values()} == {5} + result_route = result() + + assert receiver.viewer.dims.ndim == 7 + result_layer = receiver.layer_route_state.layer(result_route) + for (well, channel), route in raw_routes.items(): + layer = receiver.layer_route_state.layer(route) + state = receiver.layer_route_state.dimension_state_for(route) + assert state.axis_labels == ("site", "channel", "z_index", "timepoint", "well", "y", "x") + assert tuple(layer.contrast_limits) == (0, 500) and layer.gamma == 0.8 and layer.opacity == 0.4 + target = NapariNavigationControlMessageAction().prepare( + receiver, ViewerNavigationControlOptions( + route_key=route, axis_indices=state.presentation.route_local_component_indices( + state.presentation.projection.coordinate_index( + receiver.component_groups.existing_items_for(route)[0].address.components, + context="synthetic raw source", + ), context="synthetic raw navigation", + ), + visible=True, selected=True, + ), + ) + result_layer.visible = False + NapariNavigationControlMessageAction().apply_prepared(receiver, target) + assert receiver.viewer.dims.current_step[1] == channel - 1 + assert receiver.viewer.dims.current_step[4] == wells.index(well) + assert np.max(layer._data_view) == 10 * wells.index(well) + channel + assert well in receiver.viewer.text_overlay.text + assert f"Ch{channel}" in receiver.viewer.text_overlay.text + for other_route in raw_routes.values(): + receiver.layer_route_state.layer(other_route).visible = other_route == route + result_layer.visible = True + receiver.display_pipeline.dimension_label_overlay._update_overlay() + assert well in receiver.viewer.text_overlay.text and f"Ch{channel}" in receiver.viewer.text_overlay.text + assert np.max(layer._data_view) == 10 * wells.index(well) + channel + + @pytest.mark.parametrize("replace_layers", [False, True]) def test_clear_before_replacement_keeps_settled_pixels_inventory_and_calibration( receiver, replace_layers diff --git a/tests/unit/test_napari_streaming_handlers.py b/tests/unit/test_napari_streaming_handlers.py index e40c7435af..d14374b03d 100644 --- a/tests/unit/test_napari_streaming_handlers.py +++ b/tests/unit/test_napari_streaming_handlers.py @@ -870,7 +870,7 @@ def add_layer(self, layer): ) def _add_layer(self, layer_type, data, name, kwargs): - layer_attributes = {"name": name, "data": data, "kwargs": kwargs} + layer_attributes = {"name": name, "data": data, "kwargs": kwargs, "visible": True} if layer_type == "shapes": layer_attributes.update( { @@ -1980,8 +1980,10 @@ def component_modes(self) -> dict[str, str]: (_RegionDisplayConfig.REGION_COMPONENT, "region_mode", _RegionDisplayConfig), ], ) +@pytest.mark.parametrize("stack_origin", ["raw", "pipeline"]) +@pytest.mark.parametrize("raw_first", [True, False]) def test_paired_raw_stack_review_accepts_layer_pipeline_at_same_channel_coordinates( - component, mode_field, config_type + component, mode_field, config_type, stack_origin, raw_first ): from napari.components import ViewerModel from openhcs.core.config import NapariDimensionMode @@ -1992,16 +1994,14 @@ def test_paired_raw_stack_review_accepts_layer_pipeline_at_same_channel_coordina server.layer_route_state = NapariLayerRouteStateStore.empty() server.viewer = ViewerModel() pipeline = NapariLayerDisplayPipeline(server) - for origin, config, producer in ( - ( - "raw", config_type(), - StreamProducerIdentity("manual", "image", "raw", "raw"), - ), - ( - "pipeline", config_type(**{mode_field: NapariDimensionMode.LAYER}), - None, - ), - ): + for origin in (("raw", "pipeline") if raw_first else ("pipeline", "raw")): + config = config_type(**{ + mode_field: ( + NapariDimensionMode.STACK if origin == stack_origin + else NapariDimensionMode.LAYER + ) + }) + producer = StreamProducerIdentity("manual", "image", "raw", "raw") if origin == "raw" else None for channel in (1, 2): route = f"{origin}-{channel}" item = _layer_item( @@ -2735,8 +2735,8 @@ class FakeViewer: def test_napari_display_pipeline_uses_updated_route_when_selected_route_ndim_mismatches(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - selected_layer = object() - updated_layer = object() + selected_layer = SimpleNamespace(visible=True) + updated_layer = SimpleNamespace(visible=True) class FakeSelection: active = selected_layer @@ -2804,8 +2804,8 @@ class FakeViewer: def test_napari_display_pipeline_applies_updated_derived_route_overlay(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - selected_layer = object() - derived_layer = object() + selected_layer = SimpleNamespace(visible=True) + derived_layer = SimpleNamespace(visible=True) class FakeSelection: active = selected_layer @@ -2880,7 +2880,7 @@ def test_napari_display_pipeline_preserves_active_non_openhcs_layer_during_updat server.viewer.dims.current_step = (0, 0, 0) server.viewer.text_overlay.text = "stale OpenHCS overlay" server.viewer.layers.selection.active = object() - derived_layer = object() + derived_layer = SimpleNamespace(visible=True) server.layer_route_state.set_layer("derived", derived_layer) server.layer_route_state.set_dimension_state( "derived", @@ -2906,8 +2906,8 @@ def test_napari_display_pipeline_preserves_active_non_openhcs_layer_during_updat def test_napari_display_pipeline_falls_back_when_selected_route_lacks_current_step_labels(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - selected_layer = object() - fallback_layer = object() + selected_layer = SimpleNamespace(visible=True) + fallback_layer = SimpleNamespace(visible=True) class FakeSelection: active = selected_layer @@ -2989,8 +2989,8 @@ class FakeViewer: def test_napari_display_pipeline_falls_back_from_selected_offset_channel(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - source_layer = object() - selected_neurite_layer = object() + source_layer = SimpleNamespace(visible=True) + selected_neurite_layer = SimpleNamespace(visible=True) class FakeSelection: active = selected_neurite_layer @@ -3051,8 +3051,8 @@ class FakeViewer: def test_napari_display_pipeline_labels_all_slices_from_nonzero_axis_origin(): napari_viewer_server = pytest.importorskip("openhcs.runtime.napari_viewer_server") - map2_layer = object() - smi312_layer = object() + map2_layer = SimpleNamespace(visible=True) + smi312_layer = SimpleNamespace(visible=True) class FakeSelection: active = map2_layer @@ -5782,18 +5782,20 @@ def test_napari_shape_layer_payload_assigns_distinct_stable_label_colors(): def test_napari_shape_feature_columns_fill_sparse_late_columns_in_order(): columns = NapariShapeFeatureColumns() - columns.append({"area": 4.0}, label=1, path="first") - columns.append({"circularity": 0.8}, label=2, path="second") + columns.append({"area": 4.0}, label=1, path="first", element_identity="first:0") + columns.append({"circularity": 0.8}, label=2, path="second", element_identity="second:0") columns.append( {"area": 9.0, "circularity": 0.6}, label=3, path="third", + element_identity="third:0", ) assert columns.values == { "area": [4.0, None, 9.0], "label": [1, 2, 3], "path": ["first", "second", "third"], + NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE: ["first:0", "second:0", "third:0"], "circularity": [None, 0.8, 0.6], } @@ -6175,24 +6177,18 @@ def test_napari_points_layer_display_applies_route_global_axis_translate(): component_values={"channel": [4]}, axis_offsets=(3,), ) + item = _layer_item( + {"channel": 4}, + [{"type": "points", "coordinates": [[1, 2]], + "metadata": {"label": 7, "component": 4}}], + stream_layer_data_type=StreamingDataType.POINTS, + ) napari_viewer_server.NapariPointsLayerDisplayHandler().handle( napari_viewer_server.NapariLayerDisplayRequest( pipeline=pipeline, presentation=presentation, - items=[ - _layer_item( - {"channel": 4}, - [ - { - "type": "points", - "coordinates": [[1, 2]], - "metadata": {"label": 7, "component": 4}, - } - ], - stream_layer_data_type=StreamingDataType.POINTS, - ) - ], + items=[item], display_config=NapariDisplayConfig(), ) ) @@ -6204,7 +6200,10 @@ def test_napari_points_layer_display_applies_route_global_axis_translate(): assert tuple(data[0]) == (0, 1, 2) assert layer_kwargs["axis_labels"] == ("channel", "y", "x") assert layer_kwargs["translate"] == (3.0, 0.0, 0.0) - assert layer_kwargs["properties"] == {"label": [7], "component": [4]} + assert layer_kwargs["properties"] == { + "label": [7], "component": [4], + NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE: [item.element_identity(0)], + } def test_napari_points_layer_uses_exact_fractional_z_from_native_roi_metadata(): @@ -6333,10 +6332,17 @@ def test_shape_features_exclude_source_transport_without_mutating_payload(): full_metadata = {"label": 7, "response": 0.25, ROIArchiveSourceMetadata.FIELD: {"source_provenance": "retained"}} features = NapariShapeFeatureColumns() - features.append(full_metadata, label=7, path="/own/synthetic.roi.zip") + item = _layer_item({}, stream_layer_data_type=StreamingDataType.SHAPES) + features.append( + full_metadata, label=7, path=item.address.path, + element_identity=item.element_identity(0), + ) assert ROIArchiveSourceMetadata.FIELD in full_metadata assert ROIArchiveSourceMetadata.FIELD not in features.values assert features.values["response"] == [0.25] + assert features.values[NapariStreamLayerItem.ELEMENT_IDENTITY_FEATURE] == [ + item.element_identity(0) + ] def test_napari_fractional_z_points_are_relative_to_the_declared_anchor(): From c6e544dbd5f826cdc3209ffe7a9d12768456265a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 18:02:01 -0400 Subject: [PATCH 3/7] fix(viewer): derive promotion domains from mounted source declarations --- openhcs/runtime/napari_streaming_handlers.py | 33 ++++++++++++++++++++ openhcs/runtime/napari_viewer_server.py | 11 ++++--- tests/unit/test_napari_reset_transitions.py | 4 ++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index 06be43db51..01ecee4f66 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -34,6 +34,7 @@ ViewerComponentAxisSemantics, ViewerComponentLayout, ViewerComponentValueDomainPayload, + ViewerRouteComponentValueTracker, ViewerLayerAxisProjection, ) from openhcs.runtime.viewer_protocol import ( @@ -1709,6 +1710,38 @@ def shared_display_layout( for mounted_layout in state.display_layouts )) + def shared_component_values( + self, + tracker: ViewerRouteComponentValueTracker, + layout: ViewerComponentLayout, + *, + replacement_route: str, + additional_component_values: ComponentValues, + ) -> ComponentValues: + """Project mounted declarations, including axes previously shown as layers. + + The tracker owns observed stack coordinates. A newly shared stack slot + must also include the original source declarations of mounted peers; + those values were not stack coordinates in their former presentation. + No route domain is mutated during this preview. + """ + declarations = ( + additional_component_values, + *(state.presentation.component_values() + for route, state in self.mounted_dimension_states() + if route != replacement_route and state.presentation is not None), + ) + axes = layout.components_for_mode(ViewerComponentMode.STACK) + return tracker.shared_values_for( + axes, + replacement_route=replacement_route, + additional_component_values={ + component: [value for declaration in declarations + for value in declaration.get(component, ())] + for component in axes + }, + ) + def axis_origins_for(self, axis_labels: tuple[str, ...]) -> tuple[int, ...]: """Return normalized-viewer origins derived from mounted route offsets.""" compatible_states = tuple( diff --git a/openhcs/runtime/napari_viewer_server.py b/openhcs/runtime/napari_viewer_server.py index c904461d32..1cd7d0cd8b 100644 --- a/openhcs/runtime/napari_viewer_server.py +++ b/openhcs/runtime/napari_viewer_server.py @@ -1799,8 +1799,9 @@ def reconcile_peers(self) -> None: self.pipeline.reconcile_mounted_axis_projections( updated_route_key=self.presentation.route_key, display_layout=self.presentation.layout, - viewer_component_values=self.pipeline.server.component_values.shared_values_for( - self.presentation.display_axis_components, + viewer_component_values=self.pipeline.server.layer_route_state.shared_component_values( + self.pipeline.server.component_values, + self.presentation.layout, replacement_route=self.presentation.route_key, additional_component_values=self.presentation.component_values(), ), @@ -2690,8 +2691,9 @@ def display_layer_batch( ).layout() display_layout = self.server.layer_route_state.shared_display_layout(display_layout) projection_semantics = display_payload.for_display_layout(display_layout) - preview_values = self.server.component_values.shared_values_for( - display_layout.components_for_mode(ViewerComponentMode.STACK), + preview_values = self.server.layer_route_state.shared_component_values( + self.server.component_values, + display_layout, replacement_route=layer_key, additional_component_values=display_payload.component_values(), ) @@ -2707,6 +2709,7 @@ def display_layer_batch( items, aggregate_axis_bindings, publish=False, + viewer_component_values=preview_values, ) work = NapariLayerDisplayHandler.for_data_type(data_type).display_work( NapariLayerDisplayRequest( diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index 3fe22dc94a..859f9d346b 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -120,7 +120,9 @@ def enqueue( } ), ViewerComponentValueDomainPayload.from_ordered_wire_mapping( - {"well": domain or [well], "channel": [channel]}, context="synthetic transition" + {"well": domain or [well], "channel": [channel], + "site": [1], "z_index": [1], "timepoint": [1]}, + context="synthetic transition" ), ) context = NapariStreamLayerContext( From f25d96442fb5c0811071555bf498f4216e36b8c4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 18:04:38 -0400 Subject: [PATCH 4/7] test(viewer): declare complete grouped sources in shared-frame fixtures --- tests/unit/test_napari_reset_transitions.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index 859f9d346b..37eb7b6f54 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -85,6 +85,8 @@ def receiver(): # Exercise native selection event binding, without mounting an unrelated Qt dock. server.result_selection_controller = NapariResultSelectionController(server) server.bind_result_selection_layer = server.result_selection_controller.bind + # ViewerModel exercises native layer/dims selection, not window prominence. + server.raise_result_selection_surface = lambda: None yield server server.layer_route_state.drain_pending_updates() server.display_pipeline.clear_display_work() @@ -103,6 +105,7 @@ def enqueue( domain=None, display_config=None, channel=1, + channels=None, ): config = display_config or NapariDisplayConfig( well_mode=NapariDimensionMode.STACK, @@ -120,7 +123,7 @@ def enqueue( } ), ViewerComponentValueDomainPayload.from_ordered_wire_mapping( - {"well": domain or [well], "channel": [channel], + {"well": domain or [well], "channel": channels or [channel], "site": [1], "z_index": [1], "timepoint": [1]}, context="synthetic transition" ), @@ -219,7 +222,7 @@ def result(): receiver, [{"type": "points" if data_type is StreamingDataType.POINTS else "path", "coordinates": coordinates, "metadata": {"label": 10 * wells.index(well) + channel}}], well=well, channel=channel, producer="result", data_type=data_type, - domain=wells, display_config=result_config, + domain=wells, channels=[1, 2], display_config=result_config, ) advance_in_qt(receiver, route, update) return route @@ -264,12 +267,12 @@ def result(): assert receiver.viewer.dims.current_step[4] == wells.index(well) assert np.max(layer._data_view) == 10 * wells.index(well) + channel assert well in receiver.viewer.text_overlay.text - assert f"Ch{channel}" in receiver.viewer.text_overlay.text + assert f"Ch {channel}" in receiver.viewer.text_overlay.text for other_route in raw_routes.values(): receiver.layer_route_state.layer(other_route).visible = other_route == route result_layer.visible = True receiver.display_pipeline.dimension_label_overlay._update_overlay() - assert well in receiver.viewer.text_overlay.text and f"Ch{channel}" in receiver.viewer.text_overlay.text + assert well in receiver.viewer.text_overlay.text and f"Ch {channel}" in receiver.viewer.text_overlay.text assert np.max(layer._data_view) == 10 * wells.index(well) + channel From 1660e6e15830e99a36ab39a76a264b2c4c59bc56 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 18:11:56 -0400 Subject: [PATCH 5/7] fix(viewer): retain the source-local singleton frame during promotion --- openhcs/runtime/napari_streaming_handlers.py | 10 +++------- tests/unit/test_napari_reset_transitions.py | 3 +++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index 01ecee4f66..e9ae609c09 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -1445,14 +1445,10 @@ def preserve_native_axes( raise ValueError( f"New native slot {name!r} requires an unambiguous original route value." ) - local_index = ( - replacement.projection.component_values[name].index(routed_values[0]) - if name in replacement.projection.component_values else 0 - ) axis = replacement.axis_labels.index(name) - point[target_axes[name]] = ( - transform["translate"][axis] + local_index * transform["scale"][axis] - ) + # This newly inserted singleton's source-local index is zero. + # The original transform already contains its shared-domain offset. + point[target_axes[name]] = transform["translate"][axis] dims.point = tuple(point) retained_order = tuple(target_axes[name] for name in order) dims.order = tuple( diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index 37eb7b6f54..1578f36804 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -242,6 +242,9 @@ def result(): raw() assert {receiver.layer_route_state.layer(route).ndim for route in raw_routes.values()} == {5} result_route = result() + # The previously active D06/channel2 source remains the native frame. + assert receiver.viewer.dims.current_step[1] == 1 + assert receiver.viewer.dims.current_step[4] == 41 assert receiver.viewer.dims.ndim == 7 result_layer = receiver.layer_route_state.layer(result_route) From 6a3da22e58532b4dc3606ff8df0ec1e6f2d0199f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 18:14:56 -0400 Subject: [PATCH 6/7] test(viewer): establish the active source before asserting frame preservation --- tests/unit/test_napari_reset_transitions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_napari_reset_transitions.py b/tests/unit/test_napari_reset_transitions.py index 1578f36804..522102b38e 100644 --- a/tests/unit/test_napari_reset_transitions.py +++ b/tests/unit/test_napari_reset_transitions.py @@ -241,6 +241,9 @@ def result(): else: raw() assert {receiver.layer_route_state.layer(route).ndim for route in raw_routes.values()} == {5} + focused_route = raw_routes["D06", 2] + receiver.viewer.layers.selection.active = receiver.layer_route_state.layer(focused_route) + assert receiver.display_pipeline.dimension_label_overlay.route_resolver.resolve().route_key == focused_route result_route = result() # The previously active D06/channel2 source remains the native frame. assert receiver.viewer.dims.current_step[1] == 1 From 2dab1c7b36d7c36b8970832a4743af7a6cb0bca6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 3 Oct 2026 18:16:51 -0400 Subject: [PATCH 7/7] fix(viewer): map snapshot bounds with their semantic axes before restoring focus --- openhcs/runtime/napari_streaming_handlers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openhcs/runtime/napari_streaming_handlers.py b/openhcs/runtime/napari_streaming_handlers.py index e9ae609c09..bdad94e16b 100644 --- a/openhcs/runtime/napari_streaming_handlers.py +++ b/openhcs/runtime/napari_streaming_handlers.py @@ -1429,13 +1429,16 @@ def preserve_native_axes( dimensions = self.viewer_dimension_indices(dims.ndim) names = dict(zip(dimensions, self.axis_labels, strict=True)) points = {name: dims.point[axis] for axis, name in names.items()} + ranges = {name: dims.range[axis] for axis, name in names.items()} order = tuple(names[axis] for axis in dims.order if axis in names) yield target_dimensions = replacement.viewer_dimension_indices(dims.ndim) target_axes = dict(zip(replacement.axis_labels, target_dimensions, strict=True)) point = list(dims.point) + native_ranges = list(dims.range) for name, value in points.items(): point[target_axes[name]] = value + native_ranges[target_axes[name]] = ranges[name] transform = replacement.spatial_layer_kwargs(items, replacement.payload_axis_labels) for name in replacement.display_axis_components: if name in points: @@ -1449,6 +1452,12 @@ def preserve_native_axes( # This newly inserted singleton's source-local index is zero. # The original transform already contains its shared-domain offset. point[target_axes[name]] = transform["translate"][axis] + native_ranges[target_axes[name]] = ( + point[target_axes[name]], point[target_axes[name]], transform["scale"][axis], + ) + # Dims clips points to its ranges. Remap the detached snapshot's bounds + # first; the live viewer's bounds remain owned by its mounted layers. + dims.range = tuple(native_ranges) dims.point = tuple(point) retained_order = tuple(target_axes[name] for name in order) dims.order = tuple(