22
33from __future__ import annotations
44
5- from typing import TYPE_CHECKING
5+ from typing import TYPE_CHECKING , Callable
66from 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+ )
919from cellier .v2 .render ._scene_config import VisualRenderConfig
1020from cellier .v2 .render .render_manager import RenderManager
1121from cellier .v2 .render .visuals ._image import GFXMultiscaleImageVisual
1929if 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+
2844class 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 )
0 commit comments