Skip to content

Commit c9855f3

Browse files
add v2 event bus (#56)
1 parent 5b9e5f7 commit c9855f3

15 files changed

Lines changed: 3083 additions & 30 deletions

File tree

‎docs/cellier_v2_event_bus_design.md‎

Lines changed: 1387 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/cellier_v2_eventbus_implementation_plan.md‎

Lines changed: 609 additions & 0 deletions
Large diffs are not rendered by default.

‎scripts/v2/lut_texture_multiscale_frustum_async_cellier/example_cellier.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
--------
1818
Mouse — orbit / zoom at any time
1919
Update btn — freeze camera, plan, submit async commit task
20+
Colormap btn — toggle between viridis and gray (live, no Update needed)
2021
Frustum cull checkbox — enable / disable brick culling
2122
Show frustum checkbox — toggle wireframe visibility (no pipeline re-run)
2223
Auto / 1 / 2 / 3 radio — select force-level
@@ -32,6 +33,7 @@
3233
import pathlib
3334
import sys
3435
import time
36+
from typing import ClassVar
3537

3638
import numpy as np
3739
import pygfx as gfx
@@ -149,11 +151,14 @@ def make_frustum_wireframe(corners: np.ndarray, color: str = "#00cc44") -> gfx.L
149151
class MainWindow(QMainWindow):
150152
"""PySide6 main window using CellierController."""
151153

154+
_COLORMAPS: ClassVar[list[str]] = ["viridis", "gray"]
155+
152156
def __init__(self, data_store: MultiscaleZarrDataStore) -> None:
153157
super().__init__()
154158
self._frustum_line: gfx.Line | None = None
155159
self._update_count = 0
156160
self._force_level: int | None = None
161+
self._colormap_index: int = 0 # index into _COLORMAPS
157162

158163
self._controller = CellierController(widget_parent=self)
159164
cs = CoordinateSystem(name="world", axis_labels=("z", "y", "x"))
@@ -209,6 +214,12 @@ def _setup_ui(self, canvas_widget: QWidget) -> None:
209214
)
210215
panel_layout.addWidget(self._update_btn)
211216

217+
self._colormap_btn = QPushButton(
218+
f"Colormap: {self._COLORMAPS[self._colormap_index]}"
219+
)
220+
self._colormap_btn.clicked.connect(self._on_toggle_colormap)
221+
panel_layout.addWidget(self._colormap_btn)
222+
212223
self._frustum_cull_cb = QCheckBox("Frustum cull")
213224
self._frustum_cull_cb.setChecked(True)
214225
panel_layout.addWidget(self._frustum_cull_cb)
@@ -270,6 +281,13 @@ def _on_far_plane_changed(self, value: float) -> None:
270281
depth_range=(1.0, value),
271282
)
272283

284+
def _on_toggle_colormap(self) -> None:
285+
self._colormap_index = (self._colormap_index + 1) % len(self._COLORMAPS)
286+
new_cmap = self._COLORMAPS[self._colormap_index]
287+
self._visual.appearance.color_map = new_cmap
288+
self._colormap_btn.setText(f"Colormap: {new_cmap}")
289+
print(f"[colormap] switched to '{new_cmap}'")
290+
273291
# ── Update button handler ───────────────────────────────────────────
274292

275293
async def _on_update_clicked(self) -> None:

‎src/cellier/v2/_state.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Shared immutable state snapshots — used by events and render layer."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Literal, NamedTuple
6+
7+
8+
class DimsState(NamedTuple):
9+
"""Current dimension display state for a scene."""
10+
11+
displayed_axes: tuple[int, ...]
12+
slice_indices: tuple[int, ...]
13+
14+
15+
class CameraState(NamedTuple):
16+
"""Immutable snapshot of the active camera's logical state."""
17+
18+
camera_type: Literal["perspective", "orthographic"]
19+
position: tuple[float, float, float]
20+
rotation: tuple[float, float, float, float]
21+
up: tuple[float, float, float]
22+
fov: float
23+
zoom: float
24+
extent: tuple[float, float]
25+
depth_range: tuple[float, float]

‎src/cellier/v2/controller.py‎

Lines changed: 173 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING
5+
from typing import TYPE_CHECKING, Callable
66
from uuid import UUID, uuid4
77

8-
from cellier.v2.render._requests import DimsState
8+
from cellier.v2._state import DimsState
9+
from cellier.v2.events import (
10+
AppearanceChangedEvent,
11+
CameraChangedEvent,
12+
DimsChangedEvent,
13+
EventBus,
14+
SceneAddedEvent,
15+
SubscriptionHandle,
16+
VisualAddedEvent,
17+
VisualVisibilityChangedEvent,
18+
)
919
from cellier.v2.render._scene_config import VisualRenderConfig
1020
from cellier.v2.render.render_manager import RenderManager
1121
from cellier.v2.render.visuals._image import GFXMultiscaleImageVisual
@@ -19,12 +29,18 @@
1929
if TYPE_CHECKING:
2030
import pathlib
2131

32+
from psygnal import EmissionInfo
2233
from PySide6.QtWidgets import QWidget
2334

35+
from cellier.v2._state import CameraState
2436
from cellier.v2.data._base_data_store import BaseDataStore
2537
from cellier.v2.visuals._image import ImageAppearance
2638

2739

40+
# Appearance fields that require a reslice (not just a GPU material update).
41+
_RESLICE_FIELDS: frozenset[str] = frozenset({"lod_bias", "force_level", "frustum_cull"})
42+
43+
2844
class CellierController:
2945
"""The main class for constructing and controlling a cellier visualization.
3046
@@ -41,6 +57,13 @@ def __init__(self, widget_parent: QWidget | None = None) -> None:
4157
self._canvas_to_scene: dict[UUID, UUID] = {}
4258
# Forward map: scene_id → list[canvas_id]
4359
self._scene_to_canvases: dict[UUID, list[UUID]] = {}
60+
# Event bus
61+
self._id: UUID = uuid4()
62+
self._event_bus: EventBus = EventBus()
63+
# Cache of last-known displayed_axes per scene for change detection
64+
self._dims_cache: dict[UUID, tuple[int, ...]] = {}
65+
# Handles for externally-registered callbacks
66+
self._external_handles: list[SubscriptionHandle] = []
4467

4568
def set_widget_parent(self, parent: QWidget) -> None:
4669
"""Set the Qt parent for subsequently created canvas widgets."""
@@ -145,6 +168,8 @@ def add_scene(
145168
self._model.scenes[scene.id] = scene
146169
self._render_manager.add_scene(scene.id, dim=dim)
147170
self._scene_to_canvases[scene.id] = []
171+
self._wire_dims_model(scene)
172+
self._event_bus.emit(SceneAddedEvent(source_id=self._id, scene_id=scene.id))
148173
return scene
149174

150175
# ------------------------------------------------------------------
@@ -246,7 +271,26 @@ def add_image(
246271

247272
self._render_manager.add_visual(scene_id, gfx_visual, data)
248273
self._visual_to_scene[visual_model.id] = scene_id
249-
274+
self._wire_appearance(visual_model)
275+
self._event_bus.subscribe(
276+
AppearanceChangedEvent,
277+
gfx_visual.on_appearance_changed,
278+
entity_id=visual_model.id,
279+
owner_id=visual_model.id,
280+
)
281+
self._event_bus.subscribe(
282+
VisualVisibilityChangedEvent,
283+
gfx_visual.on_visibility_changed,
284+
entity_id=visual_model.id,
285+
owner_id=visual_model.id,
286+
)
287+
self._event_bus.emit(
288+
VisualAddedEvent(
289+
source_id=self._id,
290+
scene_id=scene_id,
291+
visual_id=visual_model.id,
292+
)
293+
)
250294
return visual_model
251295

252296
def add_visual(
@@ -417,6 +461,69 @@ def _dims_state_for_scene(self, scene_id: UUID) -> DimsState:
417461
slice_indices=dims.slice_indices,
418462
)
419463

464+
# ------------------------------------------------------------------
465+
# psygnal bridges
466+
# ------------------------------------------------------------------
467+
468+
def _wire_dims_model(self, scene: Scene) -> None:
469+
"""Subscribe to all field changes on a scene's DimsManager."""
470+
self._dims_cache[scene.id] = scene.dims.displayed_axes
471+
scene.dims.events.connect(self._make_dims_handler(scene.id))
472+
473+
def _make_dims_handler(self, scene_id: UUID) -> Callable:
474+
"""Return a psygnal catch-all handler for a scene's DimsManager."""
475+
476+
def _on_dims_psygnal(info: EmissionInfo) -> None:
477+
dims = self._model.scenes[scene_id].dims
478+
new_state = DimsState(
479+
displayed_axes=dims.displayed_axes,
480+
slice_indices=dims.slice_indices,
481+
)
482+
prev_axes = self._dims_cache[scene_id]
483+
displayed_axes_changed = prev_axes != new_state.displayed_axes
484+
self._dims_cache[scene_id] = new_state.displayed_axes
485+
self._event_bus.emit(
486+
DimsChangedEvent(
487+
source_id=self._id,
488+
scene_id=scene_id,
489+
dims_state=new_state,
490+
displayed_axes_changed=displayed_axes_changed,
491+
)
492+
)
493+
494+
return _on_dims_psygnal
495+
496+
def _wire_appearance(self, visual: MultiscaleImageVisual) -> None:
497+
"""Subscribe to all field changes on a visual's ImageAppearance."""
498+
visual.appearance.events.connect(self._make_appearance_handler(visual.id))
499+
500+
def _make_appearance_handler(self, visual_id: UUID) -> Callable:
501+
"""Return a psygnal catch-all handler for a visual's ImageAppearance."""
502+
503+
def _on_appearance_psygnal(info: EmissionInfo) -> None:
504+
field_name: str = info.signal.name
505+
new_value = info.args[0]
506+
if field_name == "visible":
507+
self._event_bus.emit(
508+
VisualVisibilityChangedEvent(
509+
source_id=self._id,
510+
visual_id=visual_id,
511+
visible=new_value,
512+
)
513+
)
514+
else:
515+
self._event_bus.emit(
516+
AppearanceChangedEvent(
517+
source_id=self._id,
518+
visual_id=visual_id,
519+
field_name=field_name,
520+
new_value=new_value,
521+
requires_reslice=(field_name in _RESLICE_FIELDS),
522+
)
523+
)
524+
525+
return _on_appearance_psygnal
526+
420527
def reslice_all(self) -> None:
421528
"""Trigger a data load for all visuals across all scenes."""
422529
for scene_id in self._model.scenes:
@@ -507,12 +614,30 @@ def add_mesh(self, *args, **kwargs):
507614
raise NotImplementedError("add_mesh is not implemented in Phase 1.")
508615

509616
def remove_scene(self, scene_id: UUID) -> None:
510-
"""Not implemented in Phase 1."""
511-
raise NotImplementedError("remove_scene is not implemented in Phase 1.")
617+
"""Clean up bus subscriptions for a scene and its canvases.
618+
619+
Render-layer teardown is not yet implemented.
620+
"""
621+
for canvas_id in self._scene_to_canvases.pop(scene_id, []):
622+
self._event_bus.unsubscribe_all(canvas_id)
623+
self._canvas_to_scene.pop(canvas_id, None)
624+
self._event_bus.unsubscribe_all(scene_id)
625+
raise NotImplementedError(
626+
"Scene render-layer teardown not yet implemented. "
627+
"Bus subscriptions have been cleaned up."
628+
)
512629

513630
def remove_visual(self, visual_id: UUID) -> None:
514-
"""Not implemented in Phase 1."""
515-
raise NotImplementedError("remove_visual is not implemented in Phase 1.")
631+
"""Clean up bus subscriptions for a visual.
632+
633+
Render-layer teardown is not yet implemented.
634+
"""
635+
self._event_bus.unsubscribe_all(visual_id)
636+
self._visual_to_scene.pop(visual_id, None)
637+
raise NotImplementedError(
638+
"Visual render-layer teardown not yet implemented. "
639+
"Bus subscriptions have been cleaned up."
640+
)
516641

517642
def remove_data_store(self, data_store_id: UUID) -> None:
518643
"""Not implemented in Phase 1."""
@@ -525,9 +650,46 @@ def get_dims_widget(self, scene_id: UUID, parent: QWidget | None = None):
525650
"""
526651
raise NotImplementedError("get_dims_widget is not implemented in Phase 1.")
527652

528-
def on_dims_changed(self, scene_id: UUID, callback) -> None:
529-
"""Register a callback to be called when dims change.
653+
def on_dims_changed(
654+
self, scene_id: UUID, callback: Callable[[DimsState], None]
655+
) -> None:
656+
"""Register a callback fired whenever the dims for *scene_id* change.
530657
531-
Not implemented in Phase 1.
658+
The callback receives a ``DimsState`` snapshot.
532659
"""
533-
raise NotImplementedError("on_dims_changed is not implemented in Phase 1.")
660+
handle = self._event_bus.subscribe(
661+
DimsChangedEvent,
662+
lambda e: callback(e.dims_state),
663+
entity_id=scene_id,
664+
owner_id=self._id,
665+
)
666+
self._external_handles.append(handle)
667+
668+
def on_camera_changed(
669+
self, scene_id: UUID, callback: Callable[[CameraState], None]
670+
) -> None:
671+
"""Register a callback fired whenever the camera for *scene_id* changes.
672+
673+
Camera event wiring is dormant in this phase; the subscription is
674+
registered but will never fire until camera events are wired.
675+
"""
676+
handle = self._event_bus.subscribe(
677+
CameraChangedEvent,
678+
lambda e: callback(e.camera_state),
679+
entity_id=scene_id,
680+
owner_id=self._id,
681+
)
682+
self._external_handles.append(handle)
683+
684+
def on_visual_changed(self, visual_id: UUID, callback: Callable) -> None:
685+
"""Register a callback fired whenever the appearance of *visual_id* changes.
686+
687+
The callback receives the live ``MultiscaleImageVisual`` model.
688+
"""
689+
handle = self._event_bus.subscribe(
690+
AppearanceChangedEvent,
691+
lambda e: callback(self.get_visual_model(e.visual_id)),
692+
entity_id=visual_id,
693+
owner_id=self._id,
694+
)
695+
self._external_handles.append(handle)

‎src/cellier/v2/events/__init__.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""cellier v2 event bus and event type catalogue."""
2+
3+
from cellier.v2._state import CameraState, DimsState
4+
from cellier.v2.events._bus import EventBus, SubscriptionHandle
5+
from cellier.v2.events._events import (
6+
AppearanceChangedEvent,
7+
CameraChangedEvent,
8+
CellierEventTypes,
9+
DataStoreContentsChangedEvent,
10+
DataStoreMetadataChangedEvent,
11+
DimsChangedEvent,
12+
FrameRenderedEvent,
13+
ResliceCancelledEvent,
14+
ResliceCompletedEvent,
15+
ResliceStartedEvent,
16+
SceneAddedEvent,
17+
SceneRemovedEvent,
18+
VisualAddedEvent,
19+
VisualRemovedEvent,
20+
VisualVisibilityChangedEvent,
21+
)
22+
23+
__all__ = [
24+
"EventBus",
25+
"SubscriptionHandle",
26+
"AppearanceChangedEvent",
27+
"CameraChangedEvent",
28+
"CameraState",
29+
"CellierEventTypes",
30+
"DataStoreContentsChangedEvent",
31+
"DataStoreMetadataChangedEvent",
32+
"DimsChangedEvent",
33+
"DimsState",
34+
"FrameRenderedEvent",
35+
"ResliceCancelledEvent",
36+
"ResliceCompletedEvent",
37+
"ResliceStartedEvent",
38+
"SceneAddedEvent",
39+
"SceneRemovedEvent",
40+
"VisualAddedEvent",
41+
"VisualRemovedEvent",
42+
"VisualVisibilityChangedEvent",
43+
]

0 commit comments

Comments
 (0)