Skip to content

Events

Overview

The EventBus is the sole communication channel between the model layer (psygnal EventedModel instances held by the controller) and the render layer (RenderManager, CanvasView, GFX*Visual). No model class ever imports a render-layer class. No render-layer class ever imports a model class. Every cross-layer communication passes through a typed event on the bus.

The bus is an internal implementation detail. Application developers never hold a reference to it. External state change notifications are surfaced through the controller.on_* callback registration methods.

This document covers: the shared state NamedTuples, the event catalogue, the bus API, bounce-back prevention, the ContextVar source-ID pattern, async handler conventions, how the controller bridges psygnal signals to bus events, and the registration lifecycle.


Module structure

src/cellier/v2/
├── _state.py          # DimsState, CameraState — shared by events and render layer
├── events/
│   ├── __init__.py    # exports EventBus, SubscriptionSpec, SubscriberInfo,
│   │                  #   SubscriptionHandle, and all event types
│   ├── _bus.py        # EventBus, SubscriptionHandle, SubscriberInfo, _Subscription
│   ├── _events.py     # all outgoing event NamedTuples
│   └── _update_events.py  # AppearanceUpdateEvent, DimsUpdateEvent,
│                          # AABBUpdateEvent, SubscriptionSpec
└── gui/
    ├── _scene.py           # QtDimsControl, QtCanvasWidget
    └── visuals/
        ├── _contrast_limits.py
        ├── _colormap.py
        ├── _level_of_detail.py
        ├── _image.py
        └── _aabb.py

GUI widgets import only from cellier.v2.events. No widget imports cellier.v2.controller.

Nothing outside src/cellier/v2/ imports from this package. The controller is the only production caller of EventBus.


Widget integration

The widget contract

Every reusable cellier widget that connects to the event bus must expose:

  • _id: UUID — a stable UUID generated at construction (one per widget instance).
  • changed: Signal = Signal(object) — psygnal signal emitted when the widget's value changes. The payload is an *UpdateEvent (AppearanceUpdateEvent, DimsUpdateEvent, or AABBUpdateEvent).
  • closed: Signal = Signal() — psygnal signal emitted when the widget closes, so the controller can clean up its bus subscriptions automatically.
  • subscription_specs() -> list[SubscriptionSpec] — declares which bus events the widget wants to receive. Returns an empty list for write-only widgets.

Widgets never import cellier.v2.controller and never hold a controller reference. They are pure UI components that communicate only through signals and SubscriptionSpec declarations. This makes them distributable as reusable plugins in separate packages that depend only on cellier.v2.events.

SubscriptionSpec

class SubscriptionSpec(NamedTuple):
    """One inbound event subscription declared by a cellier widget."""

    event_type: type
    handler: Callable[..., None]
    entity_id: UUID | None = None
    weak: bool = False

SubscriptionSpec lives in src/cellier/v2/events/_update_events.py and is re-exported from cellier.v2.events. Placing it in the events package means widgets can import it without creating a circular dependency on any render-layer or controller module.

CellierController.connect_widget

def connect_widget(
    self,
    widget: Any,
    *,
    subscription_specs: list[SubscriptionSpec] | None = None,
) -> None:

connect_widget does three things:

  1. Connects widget.changed → self._incoming_events.emit so that *UpdateEvent payloads enter the controller's incoming-event pipeline.
  2. Connects widget.closed → self.unsubscribe_owner(widget._id) so that bus subscriptions are cleaned up automatically when the widget closes.
  3. Registers each SubscriptionSpec on the outgoing event bus with owner_id=widget._id.

Typical call site after construction:

slider = QtClimRangeSlider(visual_id, clim_range=(0, 255), initial_clim=(0, 200))
controller.connect_widget(slider, subscription_specs=slider.subscription_specs())

Write-only widgets (no inbound subscriptions) omit subscription_specs:

controller.connect_widget(my_button)

Application code exception

Script-local closures and callbacks that live in the same file as the controller instantiation may call controller.incoming_events.emit() directly rather than wrapping themselves in the widget contract. The widget contract exists to enable reusable components distributed in separate packages.


Design principles

NamedTuples for all events and state snapshots

All events and shared state objects are NamedTuples — immutable, fast, and consistent with the project convention. Because Python NamedTuple subclasses cannot add fields, there is no common base class with shared fields. Instead, every event NamedTuple independently declares a source_id: UUID as its first field. The CellierEventTypes Union alias at module level groups them for type annotations on the bus.

One universal field: source_id

Every event carries a source_id: UUID identifying the object that triggered the change. This is the primary mechanism for preventing feedback loops (see §Bounce-back prevention and the ContextVar pattern). The bus itself is source-agnostic; filtering on source_id is the subscriber's responsibility.

No model references inside the bus

The bus is constructed before any model or render object exists. It holds no reference to ViewerModel, DimsManager, or any psygnal object. The controller owns all the callbacks that bridge psygnal signals to bus events (see §How the controller bridges psygnal to the bus).

Typed dispatch with optional entity filtering

Subscribers declare the event type they want, optionally scoped to a specific entity (scene, visual, canvas, data store). New event types require no changes to the bus class itself.

Synchronous bus, async-safe handlers

All bus dispatch is synchronous. Handlers that need to await work wrap it in asyncio.create_task() and return immediately. This is a documented convention, not a constraint enforced by the bus.

Strong references by default; weak references opt-in

Subscriptions hold strong references to callbacks by default. The controller's explicit unsubscribe_all(owner_id) lifecycle is the primary cleanup mechanism and is sufficient for all render-layer objects whose lifetimes the controller manages directly.

Weak references (weak=True) are a safety net for subscribers that may be garbage-collected outside the controller's teardown path — for example, transient widgets closed by Qt independently. When a weak subscription's target is collected, the entry is cleaned up lazily on the next emission of that event type.

Symmetric registration lifecycle

Every entity that is registered can be fully deregistered. All callback references and entity filters are cleaned up atomically on deregistration via unsubscribe_all(). Weak subscriptions are additionally cleaned up automatically when their target is collected, without requiring any explicit call.


Shared state NamedTuples

These are not events — they are the immutable state payloads that events carry. They live in src/cellier/v2/_state.py. This module sits above both the events package and the render layer so neither needs to import the other to reach shared types.

DimsState

DimsState is a two-level structure. The top level carries axis_labels alongside a selection object. The selection is typed as SelectionState, which is currently either an AxisAlignedSelectionState (the only concrete implementation) or the stub PlaneSelectionState.

SelectionState = AxisAlignedSelectionState | PlaneSelectionState


class AxisAlignedSelectionState(NamedTuple):
    """Immutable snapshot of an axis-aligned slice selection.

    Parameters
    ----------
    displayed_axes :
        Indices of axes currently rendered, e.g. ``(0, 1, 2)`` for 3-D
        or ``(1, 2)`` for a 2-D XY slice.
    slice_indices :
        Mapping of every world axis to its position.  A displayed axis
        keeps a stored position, which the selection ignores; the scene's
        derived ``slider_axes`` say which positions a slider shows.  An
        image in composite mode draws all of its channels whatever the
        position on its channel axis.
    """

    displayed_axes: tuple[int, ...]
    slice_indices: dict[int, float]

    def to_index_selection(self, ndim: int) -> tuple[int | slice, ...]:
        """Return a per-axis numpy indexer in axis order.

        Displayed axes → ``slice(None)``, sliced axes → their int value.
        """
        ...


class DimsState(NamedTuple):
    """Current dimension display state for a scene.

    Parameters
    ----------
    axis_labels :
        Ordered labels for every axis in the scene coordinate system,
        e.g. ``("z", "y", "x")``.
    selection :
        Immutable snapshot of which axes are displayed and the current
        slice position for all non-displayed axes.
    """

    axis_labels: tuple[str, ...]
    selection: SelectionState

Access patterns — accessing displayed axes and slice indices from a DimsState:

# in a bus event handler
displayed = event.dims_state.selection.displayed_axes  # e.g. (0, 1, 2)
z_index   = event.dims_state.selection.slice_indices.get(0, 0)

CameraState

Numpy-free immutable snapshot suitable for event payloads, external callbacks, and serialization. Distinct from ReslicingRequest, which carries numpy arrays for the hot slicing path.

class CameraState(NamedTuple):
    """Immutable snapshot of the active camera's logical state.

    For perspective cameras: ``fov`` is populated; ``extent`` is
    ``(0.0, 0.0)``.
    For orthographic cameras: ``extent`` is populated; ``fov`` is
    ``0.0``.
    ``zoom`` applies to both types.

    Parameters
    ----------
    camera_type :
        ``"perspective"`` or ``"orthographic"``.
    position :
        World-space camera position ``(x, y, z)``.
    rotation :
        Camera orientation as a unit quaternion ``(x, y, z, w)``,
        matching pygfx's convention.
    up :
        World-space up vector ``(x, y, z)``.
    fov :
        Vertical field of view in degrees.  Perspective cameras only;
        ``0.0`` for orthographic.
    zoom :
        Unitless zoom multiplier.  Applies to both camera types.
    extent :
        ``(width, height)`` in world units.  Orthographic cameras only;
        ``(0.0, 0.0)`` for perspective.
    depth_range :
        ``(near, far)`` clip distances.
    """

    camera_type: Literal["perspective", "orthographic"]
    position: tuple[float, float, float]
    rotation: tuple[float, float, float, float]
    up: tuple[float, float, float]
    fov: float
    zoom: float
    extent: tuple[float, float]
    depth_range: tuple[float, float]

CameraState and DimsState are both defined in src/cellier/v2/_state.py and re-exported from cellier.v2.events so subscribers have a single import location.


Event catalogue

All events live in src/cellier/v2/events/_events.py. All are NamedTuples. The first field of every event is source_id: UUID.

Scene / dims events

class DimsChangedEvent(NamedTuple):
    """Fired when a scene's DimsManager state changes.

    Triggers: dims slider move, programmatic mutation of
    ``dims.selection.slice_indices``, ``dims.selection.displayed_axes``.

    Primary consumers:
    - Controller: reslice the affected scene
    - Dims slider widget: update slider position without re-emitting

    Parameters
    ----------
    source_id :
        UUID of the object that caused the change.  The dims slider
        widget passes its own ID via ``controller.update_slice_indices``
        so it can echo-filter the resulting event.
    scene_id :
        The scene whose ``DimsManager`` changed.
    dims_state :
        Full snapshot of the new dims state.
    displayed_axes_changed :
        ``True`` when ``displayed_axes`` changed, not just
        ``slice_indices``.  The controller uses this flag to rebuild
        visual geometry and switch canvas cameras.
    slice_indices :
        World axis -> world slice position, for the dims widgets.
    region_changed :
        ``False`` when only a displayed axis's position or thickness
        moved: what the scene shows is unchanged, so nothing reslices.
    interactive :
        ``True`` when the change is a tick of a dims scrub: it was marked
        interactive, or it came inside an open scope.
    """

    source_id: UUID
    scene_id: UUID
    dims_state: DimsState
    displayed_axes_changed: bool
    slice_indices: Mapping[int, float] = NO_SLICE_POSITIONS
    region_changed: bool = True
    interactive: bool = False
class DimsInteractionEvent(NamedTuple):
    """A dims scrub started or ended on a scene.

    A scrub is a run of interactive slice-position changes: slider ticks,
    or programmatic moves marked ``interactive=True`` or made inside
    ``CellierController.dims_interaction``.  The start is emitted
    **before** the first tick changes the dims model.  No event is
    emitted per tick; ``DimsChangedEvent.interactive`` carries that.

    Primary consumers:
    - ``OrthoDimsController``: open a scope on the other panels
    - GUIs: a "loading full resolution" indicator

    Parameters
    ----------
    source_id :
        The source of the tick or scope that caused the transition.
    scene_id :
        The scene being scrubbed.
    phase :
        ``"start"`` or ``"end"``.
    reason :
        Why the scrub ended: ``"release"`` (the last scope closed),
        ``"settle"`` (still for ``SchedulerConfig.dims_settle_s``),
        ``"jump"`` (a change that was not interactive), or ``"cancel"``
        (the displayed axes changed).  ``None`` for a start.
    axes :
        The world axes moved by the scrub.
    """

    source_id: UUID
    scene_id: UUID
    phase: Literal["start", "end"]
    reason: Literal["release", "settle", "jump", "cancel"] | None = None
    axes: frozenset[int] = frozenset()
class CameraChangedEvent(NamedTuple):
    """Fired when the camera moves in a scene.

    Source: ``CanvasView`` detects camera movement each frame by
    comparing a cached ``CameraState`` snapshot; it emits this event
    with ``source_id = canvas_id`` when the state changes.

    The controller emits it too, for a programmatic move (``fit_camera``,
    ``look_at_visual``, ``set_camera_state``), with ``interactive=False``
    unless the move asked to be interactive or ran inside a scope.

    Primary consumers:
    - Controller: update camera model and tick the canvas's camera tracker

    Parameters
    ----------
    source_id :
        ID of the canvas that moved the camera (its ``canvas_id``, not
        the canvas's internal ``_id``).
    scene_id :
        The scene whose active camera moved.
    camera_state :
        Full snapshot of the new camera state.
    interactive :
        ``True`` for a change detected between frames and for a
        programmatic move that is a tick of a camera motion; ``False``
        for a programmatic jump.
    """

    source_id: UUID
    scene_id: UUID
    camera_state: CameraState
    interactive: bool = True
class CameraInteractionEvent(NamedTuple):
    """A camera motion started or ended on a canvas.

    The camera counterpart of ``DimsInteractionEvent``, with the same
    fields plus the canvas.  Routed by ``scene_id``.  A motion driven by
    the camera controller ends with ``"release"`` one frame after the
    camera stops (a drag released and its damped tail finished), or with
    ``"settle"`` when a drag is held still for
    ``CameraConfig.settle_threshold_s``.

    Parameters
    ----------
    source_id :
        The source of the tick or scope that caused the transition; the
        canvas's id for motion driven by its camera controller.
    scene_id :
        The scene the canvas shows.
    canvas_id :
        The canvas whose camera is moving.
    phase :
        ``"start"`` or ``"end"``.
    reason :
        ``"release"``, ``"settle"``, ``"jump"`` (a programmatic move that
        was not interactive) or ``"cancel"`` (a 2D/3D switch).  ``None``
        for a start.
    """

    source_id: UUID
    scene_id: UUID
    canvas_id: UUID
    phase: Literal["start", "end"]
    reason: Literal["release", "settle", "jump", "cancel"] | None = None

See "Interaction and progressive loading" in slicing.md for the states behind these two events.

Visual / appearance events

class AppearanceChangedEvent(NamedTuple):
    """Fired when any field on a visual's appearance model changes.

    Emitted by the controller's psygnal bridge whenever a field event
    fires on a ``BaseAppearance`` subclass.  ``visible`` field changes
    emit ``VisualVisibilityChangedEvent`` instead.

    Primary consumers:
    - ``GFX*Visual``: update material / shader parameters
    - External ``on_visual_changed`` callbacks

    Parameters
    ----------
    source_id :
        UUID of the object that caused the change.  Widget-driven
        changes carry the widget's own ID (injected via the ContextVar
        pattern in ``update_appearance_field``); direct model mutations
        fall back to the controller's ID.
    visual_id :
        The visual whose appearance changed.
    field_name :
        The name of the changed field, e.g. ``"clim"``.
    new_value :
        The new value.  Typed as ``Any``; consumers cast as needed.
    requires_reslice :
        ``True`` for fields that invalidate the current brick set
        (``settled_lod_bias``, ``force_level``, ``frustum_cull``).
        ``False`` for pure GPU-side changes (``color_map``, ``clim``).
    """

    source_id: UUID
    visual_id: UUID
    field_name: str
    new_value: Any
    requires_reslice: bool


class ChannelAppearanceChangedEvent(NamedTuple):
    """Fired when a field on one channel's appearance model changes.

    Emitted by the controller's per-channel psygnal bridge (one handler
    per channel appearance, wired in ``_wire_channels``) for image
    visuals.  Replacing ``visual.channels`` rewires those handlers, and
    reslices when the channel indices change.

    Primary consumers:
    - ``GFXImageMemoryVisual`` / ``GFXMultiscaleImageVisual``: restyle the
      channel's slot when composite mode draws it

    Parameters
    ----------
    source_id :
        UUID of the object that caused the change.  Widget-driven
        changes carry the widget's own ID; direct model mutations fall
        back to the controller's ID.
    visual_id :
        The image visual that owns the channel.
    channel_index :
        Index of the channel whose appearance changed.
    field_name :
        The name of the changed field, e.g. ``"color_map"`` or ``"clim"``.
    new_value :
        The new value.
    """

    source_id: UUID
    visual_id: UUID
    channel_index: int
    field_name: str
    new_value: Any


class PickWriteChangedEvent(NamedTuple):
    """Fired when ``BaseVisual.pick_write`` changes.

    ``source_id`` is always the controller's own ID; ``pick_write`` is
    not driven through the ContextVar widget pattern.

    Primary consumers:
    - ``GFX*Visual``: toggle whether the node writes to the pick buffer

    Parameters
    ----------
    source_id :
        Always the controller's own ID.
    visual_id :
        The visual whose ``pick_write`` changed.
    pick_write :
        The new ``pick_write`` value.
    """

    source_id: UUID
    visual_id: UUID
    pick_write: bool


class VisualVisibilityChangedEvent(NamedTuple):
    """Fired when ``appearance.visible`` changes.

    Separate from ``AppearanceChangedEvent`` so consumers that only
    need to show/hide a node do not have to inspect ``field_name``.

    Parameters
    ----------
    source_id :
        UUID of the object that caused the change.  Widget-driven
        changes carry the widget's own ID; direct model mutations fall
        back to the controller's ID.
    visual_id :
        The visual whose visibility changed.
    visible :
        The new visibility state.
    """

    source_id: UUID
    visual_id: UUID
    visible: bool


class AABBChangedEvent(NamedTuple):
    """Fired when any field on a visual's AABB params model changes.

    Primary consumers:
    - ``GFX*Visual``: update bounding-box display parameters

    Parameters
    ----------
    source_id :
        UUID of the object that caused the change.  Widget-driven
        changes carry the widget's own ID (injected via
        ``update_aabb_field``); direct model mutations fall back to
        the controller's ID.
    visual_id :
        The visual whose AABB params changed.
    field_name :
        The name of the changed field, e.g. ``"enabled"``.
    new_value :
        The new value.
    """

    source_id: UUID
    visual_id: UUID
    field_name: str
    new_value: Any


class TransformChangedEvent(NamedTuple):
    """Fired when a visual's data-to-world transform is replaced.

    Primary consumers:
    - ``GFX*Visual``: update the scene-graph node matrix

    Parameters
    ----------
    source_id :
        Always the controller's own ID; transform changes originate
        from application code via ``set_visual_transform``.
    scene_id :
        The scene that contains the visual.
    visual_id :
        The visual whose transform changed.
    transform :
        The new ``AffineTransform``.
    """

    source_id: UUID
    scene_id: UUID
    visual_id: UUID
    transform: AffineTransform

Clipping plane events

A visual's clipping_planes is a tuple of frozen ClippingPlanes; a change is always a new tuple. Three events follow it: the planes themselves, a drag of them, and the gizmo that edits one of them in a 3D canvas.

class ClippingPlanesChangedEvent(NamedTuple):
    """A visual's ``clipping_planes`` changed.

    Emitted for every change, whether it came from
    ``CellierController.set_clipping_planes`` (or a
    ``ClippingPlanesUpdateEvent``) or from assigning
    ``visual.clipping_planes`` directly.

    Parameters
    ----------
    source_id : UUID
        Who asked for the change: the widget's id for a GUI edit, otherwise
        the controller's.
    visual_id : UUID
        The visual.  The routing key.
    clipping_planes : tuple[ClippingPlane, ...]
        The complete tuple after the change.
    """

    source_id: UUID
    visual_id: UUID
    clipping_planes: Any

Emitted by the controller's psygnal bridge on the visual's clipping_planes field, so a direct assignment and set_clipping_planes give the same event. A tuple the controller refuses (a plane of another store's coordinate system) is put back on the model and emits nothing. The controller reslices the visual when its request depends on the planes.

class PlaneInteractionEvent(NamedTuple):
    """A drag of a visual's planes started or ended.

    One tracker per visual serves its clipping planes and its render planes.
    A drag is a run of ``clipping_planes`` or ``render_planes`` changes made
    inside ``CellierController.plane_interaction`` (a gizmo opens one for
    the length of a drag).  The start is emitted with the first change,
    ahead of its ``ClippingPlanesChangedEvent`` or
    ``RenderPlanesChangedEvent``.  No event is emitted per change.

    A multiscale image or labels visual in a 3D view with
    ``appearance.coarsest_while_moving_3d`` on (the default) plans nothing
    between the start and the end, and its target is planned at the end.
    Every other visual plans a plane change the same way inside a drag as
    outside one.

    Attributes
    ----------
    source_id : UUID
        The source of the change or scope that caused the transition.
    visual_id : UUID
        The visual whose planes are dragged.  The routing key.
    phase : {"start", "end"}
        Which transition this is.
    reason : {"release", "settle", "jump", "cancel"} or None
        Why the drag ended: the last scope closed, the planes were still
        for ``SchedulerConfig.dims_settle_s``, or a change arrived after
        the scopes had closed.  ``None`` for a start.
    """

    source_id: UUID
    visual_id: UUID
    phase: Literal["start", "end"]
    reason: Literal["release", "settle", "jump", "cancel"] | None = None

The scope is opened with controller.plane_interaction(visual_id) (a with block) or begin_plane_interaction / end_plane_interaction. Outside a scope a change is not a drag and emits no PlaneInteractionEvent. With no event loop there is no stillness timer, so every change inside a scope is a drag of its own: one start and one end ("settle").

class PlaneGizmoChangedEvent(NamedTuple):
    """The plane a canvas's plane gizmo edits changed.

    A canvas has at most one plane gizmo, on a clipping plane or on a
    render plane.  Emitted when one is added, when it is replaced by one on
    another plane, and when it is closed, by its owner or by itself (its
    plane or visual was removed, the canvas left 3D, a render plane's
    visual left plane mode).

    Parameters
    ----------
    source_id : UUID
        Who asked: the widget's id for a GUI toggle, otherwise the
        controller's.
    canvas_id : UUID
        The canvas.  The routing key.
    visual_id : UUID or None
        The visual whose plane the gizmo edits; ``None`` when the canvas
        has no gizmo.
    kind : {"clipping", "render"} or None
        Which of the visual's tuples the plane is in; ``None`` when the
        canvas has no gizmo.
    plane_id : UUID or None
        That plane's id; ``None`` when the canvas has no gizmo.
    """

    source_id: UUID
    canvas_id: UUID
    visual_id: UUID | None = None
    plane_id: UUID | None = None

Render planes. An image or labels visual also carries render_planes, a tuple of frozen RenderPlanes in the scene's world space, which a 3D view draws while the visual's render mode is "plane". A change is a new tuple, announced by RenderPlanesChangedEvent(source_id, visual_id, render_planes) whatever the render mode; a GUI asks for one with RenderPlanesUpdateEvent(source_id, visual_id, render_planes) on the incoming bus. While a 3D view draws the planes, a change is also a tick of the visual's plane interaction tracker, the one its clipping planes use, so one PlaneInteractionEvent pair brackets a drag of either kind.

Requests. A clipping planes control sends two update events on the incoming bus. The controller applies them and emits the events above with the request's source_id:

class ClippingPlanesUpdateEvent(NamedTuple):
    """Request to replace a visual's clipping planes.

    Fields
    ------
    source_id :
        Caller's UUID.  Stamped on the outgoing
        ``ClippingPlanesChangedEvent`` so the caller can echo-filter on its
        own subscription.
    visual_id :
        Target visual.
    clipping_planes :
        The complete new tuple of ``ClippingPlane``.  The whole tuple
        rather than one plane, so adding, removing, moving and toggling are
        one kind of request.
    """

    source_id: UUID
    visual_id: UUID
    clipping_planes: Any


class PlaneGizmoUpdateEvent(NamedTuple):
    """Request to put a canvas's plane gizmo on a plane, or close it.

    Fields
    ------
    source_id :
        Caller's UUID.  Stamped on the outgoing
        ``PlaneGizmoChangedEvent``.
    visual_id :
        The visual the plane belongs to.
    plane_id :
        The ``id`` of the ``ClippingPlane`` or ``RenderPlane``.
    canvas_id :
        The 3D canvas to draw the gizmo in.
    kind :
        ``"clipping"`` or ``"render"``: which tuple the plane is in.
    enabled :
        ``True`` opens a gizmo on the plane, replacing the canvas's current
        one.  ``False`` closes the canvas's gizmo if it is on this plane.
    """

    source_id: UUID
    visual_id: UUID
    plane_id: UUID
    canvas_id: UUID
    enabled: bool = True

In an OrthoViewer. The four panel visuals of one add_* call are linked by OrthoClippingController (ortho.clipping_controller, in cellier.convenience). It holds no planes. It subscribes to each panel visual's ClippingPlanesChangedEvent and writes the tuple to the visuals its mode links to it, with set_clipping_planes and the event's own source_id, so each linked visual emits one event per change:

  • A widget's edit is stamped with the widget's id on every linked visual, and the widget echo-filters all of them the same way.
  • The event of a visual written by the linker comes back to it and finds every linked tuple equal, so nothing more is written. That comparison is the only echo guard.
  • The bus calls subscribers in registration order and the linker subscribes when the visuals are added. A subscriber registered later hears the events of the linked visuals before the event of the visual that was edited. They all carry the same tuple.
  • A drag is forwarded: on a start from one visual the linker opens a scope on each visual linked to it, and closes them on that visual's end. Each linked visual then emits one start and one end per drag. When the dragged visual ends with "settle", the linked ones end with "release".
  • A mode change to a more linked mode writes the winning tuple (the 3D panel's) to the newly linked visuals, stamped with the linker's own id.

See docs/How_To/clipping_planes.md for the modes.

Data store events

A store announces its own changes on BaseDataStore.data_changed -- when a data field is reassigned, or when store.notify_changed(kind, regions) is called after an in-place write, or after another process wrote a tensorstore-backed store. The controller relays each announcement to the bus as one of these two events, after it has refreshed extent-derived state, and then invalidates the GPU data read from the store and reslices the visuals reading it. The reslice is capped at SchedulerConfig.store_change_max_hz (30 Hz) per store: the first change reslices at once and a burst folds into one trailing reslice. A multiscale visual is replanned only for an "extent" change; for "contents" the invalidation already refetches what it wants.

class DataStoreMetadataChangedEvent(NamedTuple):
    """A store's data may now occupy a different region (``"extent"``).

    New geometry positions, image data of a new shape, a store that grew.
    Everything derived from the store's ``axis_extents`` is stale.
    """

    source_id: UUID
    data_store_id: UUID


class DataStoreContentsChangedEvent(NamedTuple):
    """A store's values changed within the same region (``"contents"``).

    A paint stroke, new colours, a frame streamed into the existing extent.
    ``regions`` is per-axis ``(start, stop)`` in level-0 data coordinates, or
    ``None`` for anywhere.
    """

    source_id: UUID
    data_store_id: UUID
    regions: tuple[tuple[tuple[float, float], ...], ...] | None = None

Slicer lifecycle events

class ResliceStartedEvent(NamedTuple):
    """Fired before submitting async reslice tasks for a scene.

    Parameters
    ----------
    source_id :
        Always the ``SliceCoordinator``'s ID.
    scene_id :
        The scene being resliced.
    visual_ids :
        The visuals in this batch.
    """

    source_id: UUID
    scene_id: UUID
    visual_ids: frozenset[UUID]


class ResliceCompletedEvent(NamedTuple):
    """Fired after all bricks in one visual's slice batch are committed.

    Fired once per visual per reslice cycle, not once per brick.

    Parameters
    ----------
    source_id :
        Always the completing visual's ID.
    scene_id :
        The scene that contains the visual.
    visual_id :
        The visual that finished loading.
    brick_count :
        Number of bricks committed in this batch.
    """

    source_id: UUID
    scene_id: UUID
    visual_id: UUID
    brick_count: int


class LoadingProgress(NamedTuple):
    """How far a multiscale visual's latest plan has loaded.

    Summed over the visual's atlases (one per drawn channel).  Counts are
    chunks: bricks in 3D, tiles in 2D.  ``fraction`` is target resident over
    needed.
    """

    needed_backstop: int = 0
    resident_backstop: int = 0
    needed_target: int = 0
    resident_target: int = 0
    in_flight: int = 0
    failed: int = 0
    truncated_target: int = 0
    truncated_backstop: int = 0
    backstop_complete: bool = True
    complete: bool = True
    target_deferred: bool = False  # a dims-drag plan: backstop only


class ResliceProgressEvent(NamedTuple):
    """A multiscale visual's loading progress changed.

    At most once per event-loop iteration per visual: after a plan, a
    commit round that wrote its data, a given-up read, or an invalidation.
    Never per read.
    """

    source_id: UUID
    scene_id: UUID
    visual_id: UUID
    progress: LoadingProgress


class BackstopCompleteEvent(NamedTuple):
    """Every backstop chunk of a multiscale visual's latest plan is done.

    The view now shows the current slice everywhere, possibly blurry.  Once
    per plan that has a backstop.
    """

    source_id: UUID
    scene_id: UUID
    visual_id: UUID


class LoadingConfigChangedEvent(NamedTuple):
    """A multiscale visual's ``render_config.loading`` changed.

    Emitted for every change: ``CellierController.set_loading_config`` (and
    the ``LoadingConfigUpdateEvent`` a loading-settings control sends), or
    assigning ``render_config`` directly.  An invalid combination raises in
    the setter and emits nothing.
    """

    source_id: UUID
    visual_id: UUID
    loading: ProgressiveLoadingConfig


class LodConfigChangedEvent(NamedTuple):
    """A multiscale mesh's ``lod`` config changed.

    Emitted for every change: ``CellierController.set_lod_config`` (and the
    ``LodConfigUpdateEvent`` a level-of-detail control sends), or assigning
    ``visual.lod`` directly.  An invalid value, or a change of
    ``coarse_level``, raises in the setter and emits nothing.
    """

    source_id: UUID
    visual_id: UUID
    lod: GeometryLodConfig


class ResliceCancelledEvent(NamedTuple):
    """Fired when an in-flight reslice task is cancelled.

    Parameters
    ----------
    source_id :
        Always the ``SliceCoordinator``'s ID.
    scene_id :
        The scene whose task was cancelled.
    visual_id :
        The visual whose task was cancelled.
    """

    source_id: UUID
    scene_id: UUID
    visual_id: UUID

Render events

class FrameRenderedEvent(NamedTuple):
    """Fired by ``CanvasView`` at the end of each rendered frame.

    Parameters
    ----------
    source_id :
        Always the ``CanvasView``'s ``canvas_id``.
    canvas_id :
        The canvas that completed a frame.
    frame_time_ms :
        Wall-clock time for this frame in milliseconds.
    """

    source_id: UUID
    canvas_id: UUID
    frame_time_ms: float

Structural events

class VisualAddedEvent(NamedTuple):
    """Fired after a visual is fully wired into a scene."""

    source_id: UUID
    scene_id: UUID
    visual_id: UUID


class VisualRemovedEvent(NamedTuple):
    """Fired after a visual is fully removed from a scene."""

    source_id: UUID
    scene_id: UUID
    visual_id: UUID


class SceneAddedEvent(NamedTuple):
    """Fired after a scene is registered with the controller."""

    source_id: UUID
    scene_id: UUID


class SceneRemovedEvent(NamedTuple):
    """Fired after a scene and all its canvases and visuals are removed."""

    source_id: UUID
    scene_id: UUID

Canvas mouse events

Pointer interaction is surfaced as a family of typed events, one per action (press / move / release) and per camera dimensionality (2D / 3D). The 2D variants carry a world_coordinate; the 3D variants carry a ViewRay instead, because a single screen point maps to a ray rather than one world position under a perspective camera. Both carry a CanvasPickInfo describing what the pointer landed on, translated to a model-layer visual_id by the render layer (no gfx.* types leak out).

For these events the source_id is the originating canvas's ID and is also the entity-filter key (there is no separate canvas field).

Each event additionally carries the gesture context — button (the button that triggered this event), buttons (the held-button mask, the cross-backend-consistent signal for drag-vs-hover), modifiers (active keyboard modifiers), and gesture_id. gesture_id is a UUID synthesized by the render layer on press, carried verbatim through every move, and cleared on release, so the phases of one click-drag share an id; a hover-move with no active press carries None. Phase stays encoded as the event type (press / move / release).

Pick events

Mouse events report that something was hit (CanvasPickInfo.hit_visual_id); typed pick events report what was hit. There is one event per visual family, and all six share the mouse event's context fields -- source_id (the canvas), scene_id, visual_id, action, camera_type, world_coordinate (2D) or ray (3D), button, buttons, modifiers and gesture_id -- apart from pick_info:

Event Visuals pick_info
ImagePickEvent ImageVisual, MultiscaleImageVisual ImagePickInfo
LabelsPickEvent LabelMemoryVisual, MultiscaleLabelVisual LabelsPickInfo
PointsPickEvent PointsVisual PointsPickInfo
LinesPickEvent LinesVisual LinesPickInfo
MeshPickEvent MeshVisual MeshPickInfo
GraphPickEvent GraphVisual GraphNodePickInfo or GraphEdgePickInfo

Subscribe with controller.on_pick(canvas_id, event_type, callback, *, owner_id, weak=False) (mirrored on Viewer and OrthoViewer) and remove with unsubscribe_pick. A pick event is emitted only for a hit, after the mouse event for the same pointer event, and carries its gesture_id: a press, its drag moves and its release share one id, and a hover move has None.

Timing:

  • Points, lines, mesh and graph events, and in-memory image and labels events, are synchronous: they arrive straight after the mouse event.
  • Multiscale image and labels values are read at level 0 through the cancellable async slicer, so their events arrive when the read completes. A newer pointer event on the canvas cancels an in-flight move read; press and release reads always complete, and removing the visual cancels all of its reads. Match events by gesture_id and action, not by arrival order.

The element identity is decoded in the render layer from the pygfx pick payload: points report vertex_index directly; the LineSegmentMaterial lays out one explicit vertex pair per edge, so the line visual maps the picked vertex to edge_index = vertex_index // 2; image and labels visuals decode a level-0 data coordinate, which the controller completes with the planes the visual last drew.

ImagePickInfo.channel_values maps a channel index to the value at the picked voxel. A visual without a channel axis reports {0: value}; single mode reports its drawn channel; composite mode reports every drawn (visible) channel on a 2D canvas and only the pick-buffer winner's channel on a 3D canvas. LabelsPickInfo.value is the label id at the picked voxel.

Detail extraction is gated: it runs only while a canvas has at least one on_pick subscriber (RenderManager.set_pick_details_enabled, driven by the controller's per-canvas subscriber count), and image and labels values are read only while that event type has a subscriber. Mouse subscribers, and consumers that subscribe directly on the bus (e.g. the paint controller), do not enable it. hit_visual_id is always computed -- it is needed for visual-level hits and misses and is already paid for upstream by pygfx. The GPU->CPU pick-buffer readback itself is performed by pygfx before our handler runs; this gate only suppresses our own dispatch and reads.

class PointsPickInfo(NamedTuple):
    point_index: int


class LinesPickInfo(NamedTuple):
    edge_index: int


class ImagePickInfo(NamedTuple):
    data_coordinate: tuple[float, ...]
    channel_values: dict[int, float]


class LabelsPickInfo(NamedTuple):
    data_coordinate: tuple[float, ...]
    value: int


class ImagePickEvent(NamedTuple):
    source_id: UUID  # the canvas
    scene_id: UUID
    visual_id: UUID
    action: Literal["press", "move", "release"]
    camera_type: Literal["2d", "3d"]
    world_coordinate: np.ndarray | None
    ray: ViewRay | None
    button: int
    buttons: tuple
    modifiers: tuple
    gesture_id: UUID | None
    pick_info: ImagePickInfo


class CanvasPickInfo(NamedTuple):
    """Model-layer pick result attached to canvas mouse events.

    Parameters
    ----------
    hit_visual_id :
        Model-layer ID of the visual whose active scene-graph node was
        hit, or ``None`` if the pointer landed on the background.
    """

    hit_visual_id: UUID | None


class ViewRay(NamedTuple):
    """A view ray from the camera through a screen-space point.

    Used by 3D canvas mouse events in place of a single world coordinate.

    Parameters
    ----------
    origin :
        Near-plane world position where the ray starts.  Shape ``(3,)``,
        float64.
    direction :
        Unit vector in world space giving the ray direction.  Shape
        ``(3,)``, float64.
    """

    origin: np.ndarray
    direction: np.ndarray


# The 2D events (the 3D events are identical but carry `ray: ViewRay`
# in place of `world_coordinate`).
class CanvasMousePress2DEvent(NamedTuple):
    """Primary pointer button pressed on a 2D canvas."""

    source_id: UUID
    scene_id: UUID
    world_coordinate: np.ndarray
    pick_info: CanvasPickInfo
    button: int = 0
    buttons: tuple = ()
    modifiers: tuple = ()
    gesture_id: UUID | None = None


class CanvasMouseMove2DEvent(NamedTuple):
    """Pointer moved on a 2D canvas (button up or down)."""

    source_id: UUID
    scene_id: UUID
    world_coordinate: np.ndarray
    pick_info: CanvasPickInfo
    button: int = 0
    buttons: tuple = ()
    modifiers: tuple = ()
    gesture_id: UUID | None = None


class CanvasMouseRelease2DEvent(NamedTuple):
    """Primary pointer button released on a 2D canvas."""

    source_id: UUID
    scene_id: UUID
    world_coordinate: np.ndarray
    pick_info: CanvasPickInfo
    button: int = 0
    buttons: tuple = ()
    modifiers: tuple = ()
    gesture_id: UUID | None = None

RenderManager additionally emits an internal _CanvasRawPointerEvent that carries the un-dispatched pointer data (canvas/scene IDs, action, camera type, raw 2D position or ray, hit visual, button, modifiers, buttons, gesture_id, and the extracted pick_details). It is consumed only by CellierController._on_raw_pointer_event, which translates it into the public 2D/3D events above. It is not part of the public event catalogue and subscribers should not depend on it.

Union alias

CellierEventTypes = (
    DimsChangedEvent
    | DimsInteractionEvent
    | CameraChangedEvent
    | CameraInteractionEvent
    | AppearanceChangedEvent
    | ChannelAppearanceChangedEvent
    | PickWriteChangedEvent
    | AABBChangedEvent
    | VisualVisibilityChangedEvent
    | DataStoreMetadataChangedEvent
    | DataStoreContentsChangedEvent
    | ResliceStartedEvent
    | ResliceCompletedEvent
    | ResliceProgressEvent
    | BackstopCompleteEvent
    | LoadingConfigChangedEvent
    | LodConfigChangedEvent
    | ClippingPlanesChangedEvent
    | RenderPlanesChangedEvent
    | PlaneInteractionEvent
    | PlaneGizmoChangedEvent
    | ResliceCancelledEvent
    | FrameRenderedEvent
    | VisualAddedEvent
    | VisualRemovedEvent
    | TransformChangedEvent
    | SceneAddedEvent
    | SceneRemovedEvent
    | CanvasMousePress2DEvent
    | CanvasMouseMove2DEvent
    | CanvasMouseRelease2DEvent
    | CanvasMousePress3DEvent
    | CanvasMouseMove3DEvent
    | CanvasMouseRelease3DEvent
)

EventBus API

Internal subscription record: _Subscription

Subscriptions are stored as _Subscription instances rather than plain tuples. Weak reference logic is fully encapsulated in this class — there is no standalone make_weak_callback helper.

class _Subscription:
    __slots__ = (
        "_strong_cb",   # callback (strong ref)
        "_weak_func",   # weakref to unbound function (weak bound methods or weak fns)
        "_weak_obj",    # weakref to bound instance (weak bound methods only)
        "entity_field", # event field entity_id is compared with; None = canonical
        "entity_id",
        "handle",
        "is_weak",
        "owner_id",
    )

    def is_alive(self) -> bool:
        """Return True if the callback target is still reachable."""
        ...

    def call(self, event) -> None:
        """Invoke the callback. Assumes is_alive() is True."""
        ...

    def __repr__(self) -> str:
        # Produces e.g.:
        # _Subscription(callback='QtDimsControl._on_dims_changed'
        #   owner_id=3f2a... entity_id=9c1b... strong/alive)
        ...

SubscriberInfo — debugging helper

get_subscribers() returns SubscriberInfo dataclass instances (not raw _Subscription objects) for external inspection. See debug_events.md for usage.

@dataclass
class SubscriberInfo:
    callback_qualname: str   # e.g. "QtDimsControl._on_dims_changed"
    callback_instance: Any   # bound object, or None for plain functions
    owner_id: UUID | None
    entity_id: UUID | None
    is_weak: bool
    is_alive: bool

EventBus

class EventBus:
    def __init__(self) -> None:
        # event_type → list[_Subscription], in registration order
        self._subs: dict[type, list[_Subscription]] = defaultdict(list)

        # handle._id (UUID) → (event_type, _Subscription) for O(1) single removal
        self._handle_index: dict[UUID, tuple[type, _Subscription]] = {}

Note on unsubscribe_all performance. There is no forward owner index. unsubscribe_all(owner_id) scans all subscriptions across all event types linearly. This is O(n) in the total number of subscriptions. In practice the subscription count is small (tens of entries) so this has no measurable cost.

    def subscribe(
        self,
        event_type: type,
        callback: Callable,
        *,
        entity_id: UUID | None = None,
        owner_id: UUID | None = None,
        weak: bool = False,
        entity_field: str | None = None,
    ) -> SubscriptionHandle:
        """Register callback to be called when event_type is emitted.

        Parameters
        ----------
        event_type :
            The event class to subscribe to, e.g. ``DimsChangedEvent``.
        callback :
            Called synchronously for each matching event.
        entity_id :
            When provided, the callback fires only for events whose
            canonical entity ID matches this value.  ``None`` subscribes
            to all entities.
        owner_id :
            Groups this subscription for bulk removal via
            ``unsubscribe_all(owner_id)``.  Pass the owning object's own
            ``._id``.
        weak :
            Store a weak reference to the callback.  Dead weak
            subscriptions are cleaned up lazily during emission.
            Do not pass lambdas with ``weak=True``.
        entity_field :
            Name of the event field ``entity_id`` is compared with, for
            this subscription only.  ``None`` (the default) uses the
            event type's canonical field.  See "Filtering on a
            non-canonical field" below.

        Returns
        -------
        SubscriptionHandle
            Opaque token for individual removal via ``unsubscribe()``.

        Raises
        ------
        ValueError
            If ``weak=True`` is combined with a lambda.
        """
        ...

    def unsubscribe(self, handle: SubscriptionHandle) -> None: ...

    def unsubscribe_all(self, owner_id: UUID) -> None: ...

    def emit(self, event) -> None: ...

    def get_subscribers(
        self,
        event_type: type,
        *,
        entity_id: UUID | None = None,
    ) -> list[SubscriberInfo]: ...

Entity ID resolution

The bus resolves the "canonical entity ID" of an event for entity_id filtering using a module-level mapping. Each event type declares which of its fields is the filterable entity:

_ENTITY_FIELD: dict[type, str] = {
    DimsChangedEvent:                  "scene_id",
    DimsInteractionEvent:              "scene_id",
    CameraChangedEvent:                "scene_id",
    CameraInteractionEvent:            "scene_id",
    AppearanceChangedEvent:            "visual_id",
    ChannelAppearanceChangedEvent:     "visual_id",
    PickWriteChangedEvent:             "visual_id",
    AABBChangedEvent:                  "visual_id",
    VisualVisibilityChangedEvent:      "visual_id",
    DataStoreMetadataChangedEvent:     "data_store_id",
    DataStoreContentsChangedEvent:     "data_store_id",
    ResliceStartedEvent:               "scene_id",
    ResliceCompletedEvent:             "visual_id",
    ResliceProgressEvent:              "visual_id",
    BackstopCompleteEvent:             "visual_id",
    LoadingConfigChangedEvent:         "visual_id",
    LodConfigChangedEvent:             "visual_id",
    ClippingPlanesChangedEvent:        "visual_id",
    RenderPlanesChangedEvent:          "visual_id",
    PlaneInteractionEvent:          "visual_id",
    PlaneGizmoChangedEvent:    "canvas_id",
    ResliceCancelledEvent:             "visual_id",
    FrameRenderedEvent:                "canvas_id",
    VisualAddedEvent:                  "scene_id",
    VisualRemovedEvent:                "scene_id",
    TransformChangedEvent:             "visual_id",
    SceneAddedEvent:                   "scene_id",
    SceneRemovedEvent:                 "scene_id",
    CanvasMousePress2DEvent:           "source_id",
    CanvasMouseMove2DEvent:            "source_id",
    CanvasMouseRelease2DEvent:         "source_id",
    CanvasMousePress3DEvent:           "source_id",
    CanvasMouseMove3DEvent:            "source_id",
    CanvasMouseRelease3DEvent:         "source_id",
}

Filtering on a non-canonical field

An event type has one canonical field, but some events carry a second id a subscriber may want to filter on. VisualAddedEvent and VisualRemovedEvent are routed by scene_id and also carry visual_id. Passing entity_field to subscribe makes the bus compare that subscription's entity_id with the named field instead of the canonical one:

bus.subscribe(
    VisualRemovedEvent,
    callback,
    entity_id=visual_id,
    entity_field="visual_id",
)

The override belongs to the subscription, not the event type, so subscribers to the same event can filter differently:

entity_id entity_field Fires when
scene id not set any visual in that scene is removed
not set not set any visual is removed
visual id "visual_id" that visual is removed

CellierController.on_visual_added and on_visual_removed take a visual id and subscribe this way. The filter is applied by the bus rather than by a wrapper around the callback, so weak=True still references the caller's own callback and get_subscribers reports it by name.

entity_field has no effect when entity_id is None. If it names a field the event does not have, the subscription never matches; the bus does not raise.


Bounce-back prevention and the ContextVar pattern

The problem

Preventing feedback loops requires two things:

  1. Echo filtering — a widget that drives a model change must be able to identify the resulting bus event as its own and ignore it, rather than redundantly re-applying the value it just set.

  2. Re-entrancy prevention — when widget B is programmatically updated in response to a change, it must not re-broadcast that update back into the system.

Re-entrancy is handled at the widget level with Qt's blockSignals(True/False) around every programmatic setValue() call. This prevents the widget's own valueChanged signal from firing during a model-driven update.

Echo filtering requires that every bus event carry a source_id that identifies the originating widget. Widgets check if event.source_id == self._id: return at the top of their bus handlers.

The challenge: psygnal does not carry caller metadata

Widgets do not emit bus events directly. Instead they mutate model fields through controller methods, and a psygnal bridge handler fires synchronously to emit the bus event. The bridge handler's signature only receives the new field value via EmissionInfo — there is no mechanism within psygnal to pass the calling widget's UUID down to the handler.

The solution: ContextVar side-channel

A contextvars.ContextVar is set by the controller method before the model mutation and reset after. The psygnal handler, running synchronously in the same call frame, reads the var to retrieve the caller-supplied source_id.

# Module-level — not on the controller class
_source_id_override: ContextVar[UUID | None] = ContextVar(
    "_source_id_override", default=None
)
_aabb_source_id_override: ContextVar[UUID | None] = ContextVar(
    "_aabb_source_id_override", default=None
)

Two separate vars exist — one for dims and appearance mutations, one for AABB mutations — so that concurrent mutations to different sub-objects do not interfere with each other's source attribution.

The three controller methods that use this pattern:

def update_slice_indices(
    self, scene_id, slice_indices, *, source_id=None
) -> None:
    token = _source_id_override.set(source_id)
    try:
        self._model.scenes[scene_id].dims.selection.slice_indices = slice_indices
        # ↑ psygnal fires _on_dims_psygnal synchronously here
    finally:
        _source_id_override.reset(token)   # always restored, even on exception


def update_appearance_field(
    self, visual_id, field, value, *, source_id=None
) -> None:
    visual = self.get_visual_model(visual_id)
    token = _source_id_override.set(source_id)
    try:
        setattr(visual.appearance, field, value)
        # ↑ psygnal fires _on_appearance_psygnal synchronously here
    finally:
        _source_id_override.reset(token)


def update_aabb_field(
    self, visual_id, field, value, *, source_id=None
) -> None:
    visual = self.get_visual_model(visual_id)
    token = _aabb_source_id_override.set(source_id)
    try:
        setattr(visual.aabb, field, value)
        # ↑ psygnal fires _on_aabb_psygnal synchronously here
    finally:
        _aabb_source_id_override.reset(token)

Each bridge handler reads the var before emitting:

# Inside _on_appearance_psygnal (returned by _make_appearance_handler)
resolved_source_id = _source_id_override.get() or self._id
self._outgoing_events.emit(
    AppearanceChangedEvent(source_id=resolved_source_id, ...)
)

If a model field is mutated directly — bypassing the controller methods — the ContextVar is None and source_id falls back to the controller's own ID. No widget will echo-filter such an event, which is the correct behaviour: the change looks external to all subscribers.

Why ContextVar rather than a plain instance variable

ContextVar is semantically correct for async code: each asyncio.Task has its own copy of the context, so concurrent async mutations cannot stomp each other's source_id. A plain self._pending_source_id instance variable would also work in practice because psygnal fires synchronously (the bridge handler always runs and completes before set() returns, so there is no race in a single-threaded async loop), but ContextVar is the more principled choice and matches the intended use case.

Full echo-filtering example

class QtDimsControl:
    changed: Signal = Signal(object)
    closed: Signal = Signal()

    def __init__(self, scene_id, ...):
        self._id = uuid4()
        self._scene_id = scene_id
        # No controller reference — wired externally via connect_widget.

    def subscription_specs(self) -> list[SubscriptionSpec]:
        return [SubscriptionSpec(
            event_type=DimsChangedEvent,
            handler=self._on_dims_changed,
            entity_id=self._scene_id,
        )]

    def _on_dims_changed(self, event: DimsChangedEvent) -> None:
        if event.source_id == self._id:
            return  # echo from our own slider move; ignore
        for axis, sld in self._sliders.items():
            value = event.dims_state.selection.slice_indices.get(axis)
            if value is not None:
                self._set_value(axis, value)   # uses blockSignals internally

    def _submit_slider_values(self) -> None:
        updates = {axis: sld.value() for axis, sld in self._sliders.items()
                   if axis not in self._displayed_axes}
        # Emit on changed; connect_widget has wired this to _incoming_events.emit.
        self.changed.emit(DimsUpdateEvent(
            source_id=self._id,
            scene_id=self._scene_id,
            slice_indices=updates,
            displayed_axes=None,
        ))

DimsUpdateEvent carries source_id, scene_id, slice_indices, displayed_axes and interactive. Every field except source_id and scene_id is optional (None = leave unchanged), so a widget sets only the fields it owns, and slice_indices merges: send only the axes that moved. A slider sets interactive=True on every position it submits: its ticks are a scrub, not jumps.

A slider also emits DimsInteractionUpdateEvent(source_id, scene_id, phase) on the same changed signal: "begin" when it is pressed and "end" when it is released, after flushing its throttle. The controller maps them to begin_dims_interaction / end_dims_interaction, so the scrub ends on release instead of waiting for stillness.

Both guards are necessary and serve different roles:

  • source_id check — prevents the originating widget from processing its own echo (redundant work, since the model already reflects the new value).
  • blockSignals — prevents a programmatically updated widget from re-broadcasting the change into the system. Without it, widget B being updated in response to widget A's change would fire widget B's valueChanged, triggering another update_slice_indices, producing another bus event, updating widget A — an infinite loop. blockSignals is the guard that breaks that cycle.

Async handler convention

The bus dispatches synchronously. Handlers that need to await async work must call asyncio.create_task() and return immediately. Awaiting directly inside a handler will deadlock the Qt event loop.

This convention is mandatory for any handler that touches the slicer pipeline. It is documented in every such handler's docstring.

class SliceCoordinator:

    def _on_dims_changed(self, event: DimsChangedEvent) -> None:
        """Synchronous entry point — invalidates stale caches."""
        # Synchronous work only. Any async submission is the controller's
        # responsibility via reslice_scene().
        ...

How the controller bridges psygnal to the bus

The controller is the only place where psygnal models and the EventBus coexist. When a psygnal field event fires on a model — e.g. visual.appearance.clim = (0.0, 1.0) triggers appearance.events.clim.emit(...) — the controller converts it into a typed bus event and calls bus.emit().

This bridging is done by private factory methods on the controller. Each factory captures an entity ID by value and returns a closure that psygnal calls. The factory pattern is required because Python loop closures bind by reference — without it, all handlers wired in a loop would share the last iteration's variable.

Reading the new value from EmissionInfo

The bridge handlers read the new field value from info.args[0], which psygnal populates with the emitted value. They do not call Signal.current_emitter() or re-read from the model object.

def _make_appearance_handler(self, visual_id: UUID) -> Callable:
    def _on_appearance_psygnal(info: EmissionInfo) -> None:
        field_name: str = info.signal.name
        new_value = info.args[0]  # new value from psygnal args
        resolved_source_id = _source_id_override.get() or self._id
        if field_name == "visible":
            self._outgoing_events.emit(VisualVisibilityChangedEvent(
                source_id=resolved_source_id,
                visual_id=visual_id,
                visible=new_value,
            ))
        else:
            self._outgoing_events.emit(AppearanceChangedEvent(
                source_id=resolved_source_id,
                visual_id=visual_id,
                field_name=field_name,
                new_value=new_value,
                requires_reslice=(field_name in _RESLICE_FIELDS),
            ))
            if field_name in _RESLICE_FIELDS:
                self.reslice_visual(visual_id)

    return _on_appearance_psygnal

Handler storage for teardown

The controller stores every psygnal bridge handler it connects so that they can be explicitly disconnected during visual teardown. psygnal's disconnect() requires the exact handler object — closures are not equality-comparable by value, so the reference must be retained.

# On the controller instance
self._visual_psygnal_handlers: dict[UUID, list[tuple]] = {}
# Each value is a list of (signal, handler) pairs — one per _wire_* call.

The (signal, handler) pair form is used rather than storing the handler alone so that teardown can call signal.disconnect(handler) without branching on visual type.

Wiring is per-capability, not per-visual-type. At add_visual time the controller probes the model and wires whichever signals it exposes:

# In add_visual, after the model is registered
if hasattr(visual_model, "appearance"):
    self._wire_appearance(visual_model)
if hasattr(visual_model, "channels"):
    self._wire_channels(visual_model)
self._wire_aabb(visual_model)        # always
self._wire_transform(visual_model, scene_id)  # always
self._wire_pick_write(visual_model)  # always
Wiring step When Emits
_wire_appearance model has an appearance AppearanceChangedEvent / VisualVisibilityChangedEvent
_wire_image image visuals SingleAppearanceChangedEvent, ImageCompositeChangedEvent, and ChannelAppearanceChangedEvent (one handler per channel, via _wire_channels)
_wire_aabb always AABBChangedEvent
_wire_transform always TransformChangedEvent
_wire_pick_write always PickWriteChangedEvent

Each _wire_* method creates the handler, connects it, and appends the (signal, handler) pair to _visual_psygnal_handlers[visual.id]:

def _wire_appearance(self, visual) -> None:
    handler = self._make_appearance_handler(visual.id)
    visual.appearance.events.connect(handler)
    self._visual_psygnal_handlers.setdefault(visual.id, []).append(
        (visual.appearance.events, handler)
    )

DimsManager wiring

The dims bridge reads the full state from the model rather than from EmissionInfo, because DimsState is a composite snapshot of the entire DimsManager — not a single field value.

def _wire_dims_model(self, scene: Scene) -> None:
    self._dims_cache[scene.id] = scene.dims.selection.displayed_axes
    scene.dims.events.connect(self._make_dims_handler(scene.id))


def _make_dims_handler(self, scene_id: UUID) -> Callable:
    def _on_dims_psygnal(info: EmissionInfo) -> None:
        new_state = self._model.scenes[scene_id].dims.to_state()
        prev_axes = self._dims_cache[scene_id]
        displayed_axes_changed = prev_axes != new_state.selection.displayed_axes
        self._dims_cache[scene_id] = new_state.selection.displayed_axes
        if displayed_axes_changed:
            self._rebuild_visuals_geometry(scene_id, new_state.selection.displayed_axes)
            self._switch_canvas_cameras(scene_id, new_state.selection.displayed_axes)
        resolved_source_id = _source_id_override.get() or self._id
        self._outgoing_events.emit(DimsChangedEvent(
            source_id=resolved_source_id,
            scene_id=scene_id,
            dims_state=new_state,
            displayed_axes_changed=displayed_axes_changed,
        ))

    return _on_dims_psygnal

Note that _rebuild_visuals_geometry and _switch_canvas_cameras are called synchronously inside the bridge handler, before the bus event is emitted. Camera switching and visual geometry rebuilding happen as a direct consequence of the dims change, not through bus subscriptions.

AABB wiring

AABB is wired for every visual (visual: BaseVisual), not just image visuals.

def _wire_aabb(self, visual: BaseVisual) -> None:
    handler = self._make_aabb_handler(visual.id)
    visual.aabb.events.connect(handler)
    self._visual_psygnal_handlers.setdefault(visual.id, []).append(
        (visual.aabb.events, handler)
    )


def _make_aabb_handler(self, visual_id: UUID) -> Callable:
    def _on_aabb_psygnal(info: EmissionInfo) -> None:
        field_name: str = info.signal.name
        new_value = info.args[0]
        resolved_source_id = _aabb_source_id_override.get() or self._id
        self._outgoing_events.emit(AABBChangedEvent(
            source_id=resolved_source_id,
            visual_id=visual_id,
            field_name=field_name,
            new_value=new_value,
        ))

    return _on_aabb_psygnal

Channel wiring

Image visuals carry single (single mode's appearance) and a channels mapping of per-channel appearances (composite mode). _wire_image bridges all three: a field change on single, or replacing it, emits SingleAppearanceChangedEvent; a change of composite emits ImageCompositeChangedEvent, rechecks the scene's derived slider axes and reslices the visual (so a direct visual.composite = True behaves like set_image_composite, minus that method's composited-axis check); and _wire_channels connects one handler per channel, each emitting a ChannelAppearanceChangedEvent tagged with that channel's index. Replacing visual.channels rewires those handlers, and reslices when the channel indices change.

def _wire_channels(self, visual) -> None:
    for channel_index, appearance in visual.channels.items():
        handler = self._make_channel_appearance_handler(visual.id, channel_index)
        appearance.events.connect(handler)
        self._visual_psygnal_handlers.setdefault(visual.id, []).append(
            (appearance.events, handler)
        )


def _make_channel_appearance_handler(self, visual_id, channel_index) -> Callable:
    def _on_channel_appearance_psygnal(info: EmissionInfo) -> None:
        resolved_source_id = _source_id_override.get() or self._id
        self._outgoing_events.emit(ChannelAppearanceChangedEvent(
            source_id=resolved_source_id,
            visual_id=visual_id,
            channel_index=channel_index,
            field_name=info.signal.name,
            new_value=info.args[0],
        ))

    return _on_channel_appearance_psygnal

pick_write wiring

pick_write is wired for every visual. Like transforms, it does not use the ContextVar pattern — source_id is always the controller's own ID.

def _wire_pick_write(self, visual: BaseVisual) -> None:
    handler = self._make_pick_write_handler(visual.id)
    visual.events.pick_write.connect(handler)
    self._visual_psygnal_handlers.setdefault(visual.id, []).append(
        (visual.events.pick_write, handler)
    )


def _make_pick_write_handler(self, visual_id: UUID) -> Callable:
    def _on_pick_write(new_value: bool) -> None:
        self._outgoing_events.emit(PickWriteChangedEvent(
            source_id=self._id,
            visual_id=visual_id,
            pick_write=new_value,
        ))

    return _on_pick_write

Transform wiring

Transform changes do not use the ContextVar pattern because transforms are not driven by GUI sliders — they come from application code via set_visual_transform. The source_id is always the controller's own ID.

def _make_transform_handler(self, visual_id: UUID, scene_id: UUID) -> Callable:
    def _on_transform(new_transform: AffineTransform) -> None:
        self._outgoing_events.emit(TransformChangedEvent(
            source_id=self._id,
            scene_id=scene_id,
            visual_id=visual_id,
            transform=new_transform,
        ))
        if not self._suppress_reslice:
            self.reslice_scene(scene_id)

    return _on_transform

Registration and deregistration lifecycle

Every render-layer object passes its own ID as owner_id when subscribing. When the controller removes an entity, it calls bus.unsubscribe_all(entity_id) to clean up all subscriptions registered by that entity atomically.

# Controller: wiring a new GFX visual
bus.subscribe(
    AppearanceChangedEvent,
    gfx_visual.on_appearance_changed,
    entity_id=visual_id,
    owner_id=visual_id,
)
bus.subscribe(
    AABBChangedEvent,
    gfx_visual.on_aabb_changed,
    entity_id=visual_id,
    owner_id=visual_id,
)
bus.subscribe(
    VisualVisibilityChangedEvent,
    gfx_visual.on_visibility_changed,
    entity_id=visual_id,
    owner_id=visual_id,
)
bus.subscribe(
    TransformChangedEvent,
    gfx_visual.on_transform_changed,
    entity_id=visual_id,
    owner_id=visual_id,
)

# Controller: removing the visual
bus.unsubscribe_all(owner_id=visual_id)  # removes all four in one call

Visual teardown sequence

A visual has two independent sets of subscriptions that must both be cleaned up:

  1. psygnal side — the bridge closures stored in _visual_psygnal_handlers. These live on psygnal EventedModel signals (appearance.events, aabb.events, events.transform) and are disconnected by calling signal.disconnect(handler) for each stored (signal, handler) pair.

  2. bus side — the GFX-layer subscriptions registered with owner_id=visual_id (appearance, AABB, visibility, transform handlers on GFX*Visual). Removed atomically with bus.unsubscribe_all(visual_id).

The teardown order inside remove_visual is:

1. scene.visuals.remove(visual_model)          # model layer
2. signal.disconnect(handler) for each pair    # psygnal bridge: stop emitting events
3. bus.unsubscribe_all(visual_id)              # bus: stop receiving events in GFX layer
4. _visual_to_scene.pop(visual_id)             # controller map
5. render_manager.remove_visual(visual_id)     # scene graph + GPU ref release
6. bus.emit(VisualRemovedEvent(...))            # notify external observers

Psygnal is disconnected before the bus so that no new bus events can be emitted from stale bridge closures during or after step 5. The bus is unsubscribed before render-layer removal so that the GFX handlers cannot fire on a node that is already removed from the scene graph.

remove_scene cascades: it calls remove_visual for every child visual before tearing down the scene-level maps, then delegates to render_manager.remove_scene(scene_id) which drops references to the gfx.Scene and all CanvasView objects (GC then reclaims GPU resources — pygfx has no explicit destroy API).

External callbacks registered by the application developer receive the full event object, not a stripped-down state payload:

# Application code
handle = controller.on_dims_changed(scene_id, my_callback, owner_id=my_id)

# my_callback receives the full DimsChangedEvent:
def my_callback(event: DimsChangedEvent) -> None:
    print(event.source_id, event.dims_state.selection.displayed_axes)

Subscription matrix

Canonical specification for what the controller wires at construction time or at object registration time.

Subscriber Event type entity_id filter Action
Controller DimsChangedEvent (none — all scenes) Call reslice_scene for the changed scene (during a scrub, backstop-only for visuals with coarsest_while_moving on for the view; the scrub's end plans them in full)
Controller CameraChangedEvent (none — all canvases) Update camera model; tick the canvas's camera tracker (a motion's end, or a jump, reslices)
Controller _CameraControllerEvent (internal) (none — all canvases) Open or close the camera controller's scope on the canvas's camera tracker
Controller FrameRenderedEvent (none — all canvases) With no event loop, run the camera reslices the frame queued
OrthoDimsController DimsInteractionEvent scene_id (each panel) Open a dims interaction scope on the other panels while one is scrubbed
Controller ClippingPlanesUpdateEvent (incoming) (none) Call set_clipping_planes with the request's source_id
Controller RenderPlanesUpdateEvent (incoming) (none) Call set_render_planes with the request's source_id
Controller PlaneGizmoUpdateEvent (incoming) (none) Open the canvas's gizmo on the plane, or close it
ClippingPlaneGizmoController ClippingPlanesChangedEvent visual_id Move the gizmo to its plane's new pose; close when the plane is gone or can no longer have a gizmo
ClippingPlaneGizmoController VisualRemovedEvent scene_id Close the gizmo when its visual is removed
RenderPlaneGizmoController PlaneGizmoMovedEvent gizmo_id Assign the fields the held handle changes: origin (translate), the in-plane axes (rotate), one extent (scale); one plane interaction scope per drag
RenderPlaneGizmoController RenderPlanesChangedEvent visual_id Move the gizmo to its plane's new frame and show a scale handle on each bounded axis; close when the plane is gone
RenderPlaneGizmoController DimsChangedEvent, VisualRemovedEvent scene_id Close when the view leaves 3D or stops displaying the plane's axes, or the visual is removed (the controller closes it when the visual leaves plane mode)
OrthoClippingController ClippingPlanesChangedEvent visual_id (each panel visual) Write the tuple to the panel visuals linked to it whose tuple differs (set_clipping_planes, same source_id)
OrthoClippingController PlaneInteractionEvent visual_id (each panel visual) Open a plane interaction scope on the linked panel visuals while one is dragged
OrthoClippingController VisualRemovedEvent visual_id (each panel visual) Close the scopes forwarded from the visual, drop its subscriptions, take it out of its group
External callback DimsInteractionEvent scene_id Fire on_dims_interaction user callback
External callback CameraInteractionEvent scene_id Fire on_camera_interaction user callback
GFX*Visual AppearanceChangedEvent visual_id Apply material / shader parameter
GFX*Visual AABBChangedEvent visual_id Update bounding-box display
GFX*Visual VisualVisibilityChangedEvent visual_id Show / hide scene-graph node
GFX*Visual TransformChangedEvent visual_id Update scene-graph node matrix
External callback DimsChangedEvent scene_id Fire on_dims_changed user callback
External callback CameraChangedEvent scene_id Fire on_camera_changed user callback
External callback AppearanceChangedEvent visual_id Fire on_visual_changed user callback
External callback AABBChangedEvent visual_id Fire on_aabb_changed user callback
External callback ResliceStartedEvent scene_id Fire on_reslice_started user callback
External callback ResliceCompletedEvent visual_id Fire on_reslice_completed user callback
External callback ResliceProgressEvent visual_id Fire on_reslice_progress user callback (multiscale visuals only)
External callback BackstopCompleteEvent visual_id Fire on_backstop_complete user callback (multiscale visuals only)
Loading indicator ResliceProgressEvent visual_id Redraw the bar and status line (QtLoadingIndicator, AnywidgetLoadingIndicator)
Loading-settings control LoadingConfigChangedEvent visual_id Show the visual's ProgressiveLoadingConfig (QtLoadingConfigControls, AnywidgetLoadingConfigControls); edits go out as LoadingConfigUpdateEvent on the incoming bus, and a refused edit shows the reason
Level-of-detail control LodConfigChangedEvent visual_id Show a multiscale mesh's GeometryLodConfig (QtLodConfigControls, AnywidgetLodConfigControls); edits go out as LodConfigUpdateEvent on the incoming bus, and a refused edit shows the reason
External callback LodConfigChangedEvent visual_id Fire on_lod_config_changed user callback
Clipping planes control ClippingPlanesChangedEvent visual_id (each visual it edits) Show the planes (QtClippingPlanesControls, AnywidgetClippingPlanesControls); edits go out as one ClippingPlanesUpdateEvent per visual on the incoming bus
Clipping planes control PlaneGizmoChangedEvent canvas_id With a gizmo target: show which plane has the canvas's gizmo; the toggle goes out as PlaneGizmoUpdateEvent
External callback ClippingPlanesChangedEvent visual_id Fire on_clipping_planes_changed user callback
External callback RenderPlanesChangedEvent visual_id Fire on_render_planes_changed user callback
External callback PlaneInteractionEvent visual_id Fire on_plane_interaction user callback
External callback PlaneGizmoChangedEvent canvas_id Fire on_plane_gizmo_changed user callback
External callback CanvasMouse{Press,Move,Release}{2D,3D}Event canvas_id Fire on_mouse_* user callback
External callback {Image,Labels,Points,Lines,Mesh,Graph}PickEvent canvas_id Fire on_pick user callback; enables pick-detail extraction
Paint controller CanvasMouse{Press,Move,Release}2DEvent canvas_id Accumulate brush stroke (direct bus subscription; does not enable pick details)

SliceCoordinator and the controller subscribe without entity_id filters because they manage all scenes centrally. Camera switching and visual geometry rebuilding on dims changes happen synchronously inside the controller's psygnal bridge handler (_make_dims_handler), not through bus subscriptions.

CanvasView is not a bus subscriber. It emits CameraChangedEvent (detected by comparing a cached CameraState snapshot each frame, after ticking its camera controller) and the internal _CameraControllerEvent, but does not subscribe to any event type. The controller receives both and handles model sync, the camera tracker and reslicing.


End-to-end example: appearance change propagation

This example traces a single colormap change through the full system, starting from a widget that follows the decoupled widget contract.

QtClimRangeSlider
      ↓  changed.emit(AppearanceUpdateEvent(source_id=slider._id, field="clim", ...))
controller._incoming_events   ← connect_widget wired slider.changed → this bus
      ↓
_on_appearance_update()       ← incoming bus subscriber; calls update_appearance_field
      ↓
update_appearance_field()     ← sets ContextVar; mutates visual.appearance.clim
      ↓
psygnal EventedModel          ← clim signal fires synchronously
      ↓
Controller bridge             ← reads ContextVar; emits AppearanceChangedEvent
      ↓
EventBus.emit()               ← dispatches to subscribers
      ↙ requires_reslice=False          requires_reslice=True ↘
GFX*Visual                   Controller
on_appearance_changed()       reslice_scene() → RenderManager
      ↓                                ↓
pygfx renderer               AsyncSlicer
deferred GPU upload           concurrent brick reads

The trigger

The slider emits its changed signal with an AppearanceUpdateEvent payload. connect_widget has already wired slider.changed → controller._incoming_events.emit, so the event enters the controller's incoming pipeline without any direct controller call in widget code:

# Inside QtClimRangeSlider._on_slider_changed — no controller import required:
self.changed.emit(
    AppearanceUpdateEvent(
        source_id=self._id,
        visual_id=self._visual_id,
        field="clim",
        value=value,
    )
)

Stage 1 — Incoming bus dispatches to _on_appearance_update; model is mutated

_on_appearance_update receives the AppearanceUpdateEvent and forwards it to update_appearance_field, preserving source_id:

def _on_appearance_update(self, event: AppearanceUpdateEvent) -> None:
    self.update_appearance_field(
        event.visual_id, event.field, event.value, source_id=event.source_id
    )

update_appearance_field sets _source_id_override to slider._id, then sets visual.appearance.clim = (0.0, 1.0). psygnal fires _on_appearance_psygnal synchronously before setattr returns.

Stage 2 — Bridge handler reads ContextVar and emits bus event

def _on_appearance_psygnal(info: EmissionInfo) -> None:
    field_name = info.signal.name  # "clim"
    new_value = info.args[0]  # (0.0, 1.0)
    resolved_source_id = _source_id_override.get() or self._id  # widget._id
    self._outgoing_events.emit(AppearanceChangedEvent(
        source_id=resolved_source_id,
        visual_id=visual_id,
        field_name="clim",
        new_value=(0.0, 1.0),
        requires_reslice=False,
    ))

_source_id_override is reset by update_appearance_field's finally block after setattr returns.

Stage 3 — EventBus dispatches to subscribers

emit() dispatches AppearanceChangedEvent to all matching subscriptions. Two fire:

  1. GFXMultiscaleImageVisual.on_appearance_changed — registered with entity_id=visual_id
  2. Any external on_visual_changed callback registered by the application

The widget that triggered the change also has a subscription to AppearanceChangedEvent. It checks if event.source_id == self._id: return and skips the event. blockSignals is not needed here — the widget is only reading the event, not driving a Qt signal.

Stage 4 — GFXMultiscaleImageVisual.on_appearance_changed

The render-layer handler updates the pygfx material directly. No async work is initiated — that is the controller's responsibility for requires_reslice=True fields.

Stage 5 — pygfx deferred GPU upload

On the next call to renderer.render() in CanvasView._draw_frame, pygfx detects the dirty material and uploads the updated parameters to the GPU.

The reslice path: settled_lod_bias, force_level, frustum_cull

When a field in _RESLICE_FIELDS changes, AppearanceChangedEvent carries requires_reslice=True. The GFX*Visual handler does nothing for these fields. The reslice is triggered directly inside the psygnal bridge _on_appearance_psygnal, which calls self.reslice_visual(visual_id) after emitting the event — it is not driven by a separate controller bus subscription. The updated field value is read from the live model at planning time, so the event payload itself does not need to carry planning parameters.


What does not flow through the bus

  • Data payloads. Brick data from AsyncSlicer is delivered directly to GFX*Visual.on_data_ready() via the callback argument to slicer.submit(). Multiscale bricks and tiles go through the chunk scheduler instead, which writes them with its Residency adapters in a commit round before a frame. Only the lifecycle signals go through the bus: ResliceCompletedEvent, and for multiscale visuals ResliceProgressEvent and BackstopCompleteEvent. CellierController.loading_progress(visual_id) reads the same counts on demand.
  • Structural add/remove operations. add_visual(), remove_visual(), etc. are synchronous and handled atomically by the controller. VisualAddedEvent and VisualRemovedEvent are emitted after the operation completes, for external observers such as a layer list widget.
  • Frame render loop. The pygfx render loop runs via rendercanvas. CanvasView emits FrameRenderedEvent from its draw callback, but the loop itself is not bus-driven.

Module exports

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

from cellier._state import CameraState, DimsState
from cellier.events._bus import EventBus, SubscriberInfo, SubscriptionHandle
from cellier.events._events import (
    AABBChangedEvent,
    AppearanceChangedEvent,
    CameraChangedEvent,
    CameraInteractionEvent,
    CanvasMouseMove2DEvent,
    CanvasMouseMove3DEvent,
    CanvasMousePress2DEvent,
    CanvasMousePress3DEvent,
    CanvasMouseRelease2DEvent,
    CanvasMouseRelease3DEvent,
    CanvasPickInfo,
    CellierEventTypes,
    ChannelAppearanceChangedEvent,
    PlaneInteractionEvent,
    PlaneGizmoChangedEvent,
    ClippingPlanesChangedEvent,
    RenderPlanesChangedEvent,
    DataStoreContentsChangedEvent,
    DataStoreMetadataChangedEvent,
    DimsChangedEvent,
    DimsInteractionEvent,
    FrameRenderedEvent,
    GraphPickEvent,
    ImagePickEvent,
    ImagePickInfo,
    LabelsPickEvent,
    LabelsPickInfo,
    LinesPickEvent,
    LinesPickInfo,
    MeshPickEvent,
    MeshPickInfo,
    PickWriteChangedEvent,
    PointsPickEvent,
    PointsPickInfo,
    ResliceCancelledEvent,
    ResliceCompletedEvent,
    ResliceProgressEvent,
    ResliceStartedEvent,
    SceneAddedEvent,
    SceneRemovedEvent,
    TransformChangedEvent,
    ViewRay,
    VisualAddedEvent,
    VisualPickDetails,
    VisualRemovedEvent,
    VisualVisibilityChangedEvent,
)
from cellier.events._update_events import (
    AABBUpdateEvent,
    AppearanceUpdateEvent,
    CellierUpdateEventTypes,
    PlaneGizmoUpdateEvent,
    ClippingPlanesUpdateEvent,
    RenderPlanesUpdateEvent,
    DimsInteractionUpdateEvent,
    DimsUpdateEvent,
    SubscriptionSpec,
)

Pending

  • Higher-level interaction events. The low-level canvas pointer events (press / move / release, 2D and 3D — see §Canvas mouse events) are implemented, with synthesized gesture_id and typed pick events for element identity. A library gesture layer (compound on_drag, double-click via event.clicks) is deliberately deferred; apps assemble gestures from the phase events plus gesture_id, as the paint controller does. Higher-level semantic events for selection and annotation — e.g. a SelectionChangedEvent — are still to be designed once that interaction model is specified. They will follow the same NamedTuple / source_id pattern.
  • Upstream pick-readback suppression. The true Decision-4 perf win — stopping pygfx from performing the GPU→CPU readback when no consumer needs it — is pending a pygfx pick-configuration investigation. The current gate only skips our own cheap dispatch.