Skip to content

API Reference#

scenex #

Declarative scene graph library for scientific visualization.

scenex is a Python library for creating interactive 3D visualizations using a declarative scene graph model. It provides a backend-agnostic API that works with multiple rendering engines (pygfx, vispy) while maintaining a consistent, intuitive interface.

Key Features
  • Declarative API: Describe how the scene should look rather than how to render it.
  • Evented models: Events enable painless reaction to changes in the scene graph
  • Multiple backends: Render with pygfx (WebGPU) or vispy (OpenGL)
Quick Start

Create and display a simple visualization::

import numpy as np
import scenex as snx

# Create a random image
data = np.random.rand(100, 100)
img = snx.Image(data=data)

# Show it
snx.show(img)
snx.run()
See Also
  • scenex.model: Core declarative model classes
  • scenex.adaptors: Backend adaptor implementations
  • scenex.app: Application and event handling

Modules:

  • adaptors

    Backend adaptors that translate scenex models into graphics library calls.

  • app

    Application and GUI framework abstraction layer.

  • conftest

    Pytest setup for doctests.

  • imgui

    ImGui controls for interactive scenex visualization.

  • model

    Declarative model classes for building scene graphs.

  • util

    Utility functions for displaying and debugging scenex visualizations.

  • utils

    Utilities for working with scenex structures.

Classes:

  • Camera

    A camera that defines the viewing perspective and projection for a scene.

  • CameraController

    Base class defining how a camera responds to user interaction events.

  • Canvas

    A rendering surface that displays one or more views.

  • ColorModel

    Base class for color models used in scene nodes.

  • Coord

    Distance along a number of pixels. Expressed using CSS-style strings.

  • FaceColors

    Per-face coloring strategy for mesh-like nodes.

  • Image

    A 2D image rendered as a textured rectangle.

  • Layout

    Style model for a view's border, padding, background, and placement.

  • Letterbox

    Maintain content aspect ratio on resize via letterboxing/pillarboxing.

  • Line

    A polyline defined by connected vertices.

  • Mesh

    A 3D surface mesh composed of triangular faces.

  • Node

    Base class for all nodes in the scene graph.

  • Orbit

    3D orbit controller for rotating around a focal point.

  • PanZoom

    2D pan and zoom controller for orthographic views.

  • Points

    A collection of point markers rendered at specified coordinates.

  • ResizePolicy

    Base class defining how a view adapts to changes in its layout dimensions.

  • Scene

    The root container node for a scene graph.

  • Text

    A text label positioned in 3D world space.

  • Transform

    A 4x4 homogeneous transformation matrix for 3D affine transformations.

  • UniformColor

    Uniform coloring strategy for scene nodes.

  • VertexColors

    Per-vertex coloring strategy for mesh, line, or points nodes.

  • View

    A rectangular viewport that displays a scene through a camera.

  • Volume

    A 3D volumetric dataset rendered with volume rendering techniques.

Functions:

  • native

    Get the native widget for the given canvas.

  • run

    Start the GUI event loop to display interactive visualizations.

  • set_cursor

    Set the cursor for the given canvas.

  • show

    Display a visualization by creating a canvas and making it visible.

  • use

    Set the graphics backend for rendering scenex visualizations.

Camera #

Camera(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Camera[Camera]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Camera
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Camera href "" "scenex.Camera"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A camera that defines the viewing perspective and projection for a scene.

The Camera is a node in the scene graph that determines how 3D world space is projected onto a 2D canvas. It combines a view transformation (positioning the camera in the scene) with a projection transformation (defining the viewing volume and perspective).

Cameras use two transforms: - transform (inherited from Node): Maps local 3D space to world 3D space, positioning and orienting the camera in the scene. - projection: Maps normalized device coordinates [-1, 1] x [-1, 1] to rays in local 3D space, defining the viewing volume and projection type.

The camera uses a right-handed coordinate system following OpenGL conventions: the positive x-axis points right, the positive y-axis points up, and the positive z-axis points out of the screen toward the viewer.

Examples:

Create a camera with pan-zoom controller: >>> camera = Camera(controller=PanZoom(), interactive=True)

Create a camera with orbit controller: >>> camera = Camera(controller=Orbit(center=(0, 0, 0)), interactive=True)

Position a camera and point it at a target: >>> camera = Camera() >>> camera.transform = Transform().translated((10, 0, 0)) >>> camera.look_at((0, 0, 0), up=(0, 0, 1))

Create a perspective camera: >>> from scenex.utils.projections import perspective >>> camera = Camera(projection=perspective(fov=70, near=0.1, far=100))

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • look_at

    Adjusts the camera to look at a target point in the world.

  • model_post_init

    Called after the model is initialized.

  • passes_through

    Returns the depth t at which the provided ray intersects this node.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

  • children (tuple[Node, ...]) –

    Return a tuple of the children of this node.

  • forward (Vector3D) –

    The forward direction of the camera in world space, as a unit vector.

  • up (Vector3D) –

    The up direction of the camera in world space, as a unit vector.

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

forward property writable #

forward: Vector3D

The forward direction of the camera in world space, as a unit vector.

up property writable #

up: Vector3D

The up direction of the camera in world space, as a unit vector.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

look_at #

look_at(target: Position3D, /, *, up: Vector3D | None = None) -> None

Adjusts the camera to look at a target point in the world.

Parameters:

  • target #

    (Position3D) –

    The position in 3D space that the camera should look at.

  • up #

    (Vector3D | None, default: None ) –

    The up direction for the camera. If provided, this vector must be perpendicular to the forward vector that results from looking at target.

Source code in src/scenex/model/_nodes/camera.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def look_at(self, target: Position3D, /, *, up: Vector3D | None = None) -> None:
    """Adjusts the camera to look at a target point in the world.

    Parameters
    ----------
    target: Position3D
        The position in 3D space that the camera should look at.
    up: Vector3D, optional
        The up direction for the camera. If provided, this vector must be
        perpendicular to the forward vector that results from looking at target.
    """
    position = self.transform.map((0, 0, 0))[:3]
    self.forward = tuple(target - position)
    if up is not None:
        if np.linalg.norm(up) == 0:
            raise ValueError("Up vector must be non-zero.")
        if np.abs(np.dot(self.forward, up)) > 1e-6:
            raise ValueError("Up vector must be perpendicular to forward vector.")
        self.up = up

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

passes_through #

passes_through(ray: Ray) -> float | None

Returns the depth t at which the provided ray intersects this node.

The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t, where t>=0

Parameters:

  • ray #

    (Ray) –

    The ray passing through the scene

Returns:

  • t ( float | None ) –

    The depth t at which the ray intersects the node, or None if it never intersects.

Source code in src/scenex/model/_nodes/node.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def passes_through(self, ray: Ray) -> float | None:
    """Returns the depth t at which the provided ray intersects this node.

    The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t,
    where t>=0

    Parameters
    ----------
    ray : Ray
        The ray passing through the scene

    Returns
    -------
    t: float | None
        The depth t at which the ray intersects the node, or None if it never
        intersects.
    """
    # Nodes that want to support ray intersection should override this method.
    return None

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

CameraController #

Bases: BaseModel


              flowchart TD
              scenex.CameraController[CameraController]

              

              click scenex.CameraController href "" "scenex.CameraController"
            

Base class defining how a camera responds to user interaction events.

A CameraController handles user input (mouse, keyboard, wheel) to manipulate camera transforms and projections, enabling interactive behaviors like panning, zooming, orbiting, or custom camera controls. Controllers are attached to Camera instances via the controller field and automatically receive events when the camera is marked as interactive=True.

Event handlers should return True if they fully handled the event (stopping further propagation) or False if other handlers should continue processing the event.

Examples:

Create a camera with pan/zoom controller: >>> camera = Camera(controller=PanZoom(), interactive=True)

Create a camera with orbit controller: >>> camera = Camera(controller=Orbit(center=(0, 0, 0)), interactive=True)

See Also

PanZoom : 2D pan and zoom controller Orbit : 3D orbit controller Camera : Camera class that uses controllers

Methods:

  • handle_event

    Handle a user interaction event to control the camera.

handle_event abstractmethod #

handle_event(event: Event, view: View) -> bool

Handle a user interaction event to control the camera.

This method is called automatically on all events on the camera's view that were not handled by previous handlers during scenex event processing.

Parameters:

  • event #

    (Event) –

    The input event to handle (MouseMoveEvent, MousePressEvent, WheelEvent, KeyPressEvent, etc.)

  • view #

    (View) –

    The view containing the camera to manipulate.

Returns:

  • bool

    True if the event was fully handled and should not propagate to other handlers, False if not handled or other handlers should process it.

Notes

A View is passed rather than a Camera directly because controllers need view.to_ray() to unproject screen-space event positions into world space, which requires both the camera matrices and the viewport dimensions.

Source code in src/scenex/model/_nodes/camera.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
@abstractmethod
def handle_event(self, event: Event, view: View) -> bool:
    """
    Handle a user interaction event to control the camera.

    This method is called automatically on all events on the camera's view that were
    not handled by previous handlers during scenex event processing.

    Parameters
    ----------
    event : Event
        The input event to handle (MouseMoveEvent, MousePressEvent, WheelEvent,
        KeyPressEvent, etc.)
    view : View
        The view containing the camera to manipulate.

    Returns
    -------
    bool
        True if the event was fully handled and should not propagate to other
        handlers, False if not handled or other handlers should process it.

    Notes
    -----
    A ``View`` is passed rather than a ``Camera`` directly because controllers
    need ``view.to_ray()`` to unproject screen-space event positions into world
    space, which requires both the camera matrices and the viewport dimensions.
    """
    ...

Canvas #

Canvas(*, views: Iterable[View] = (), **data: Unpack[CanvasKwargs])

Bases: EventedBase


              flowchart TD
              scenex.Canvas[Canvas]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._base.EventedBase --> scenex.Canvas
                


              click scenex.Canvas href "" "scenex.Canvas"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A rendering surface that displays one or more views.

The Canvas represents the top-level rendering context where views are displayed. In desktop applications, a canvas corresponds to a window. In web applications, it corresponds to a DOM element. Multiple views can be arranged on a single canvas using their layout parameters.

Examples:

Create a simple canvas with default settings: >>> canvas = Canvas()

Create a canvas with custom size and title: >>> canvas = Canvas(width=800, height=600, title="My Visualization")

Create a canvas with multiple views side-by-side: >>> canvas = Canvas(width=800, height=400, views=[View(), View()])

Methods:

  • close

    Close the canvas and release resources.

  • content_rect_for

    The pixel rect (x, y, width, height) of the content area for a view.

  • filter_event

    Pass event through the canvas-level filter, if any.

  • handle

    Handle the passed event.

  • model_post_init

    Post-initialization hook for the model.

  • rect_for

    The pixel rect (x, y, width, height) for a view, computed from its layout.

  • render

    Render the canvas to an image array.

  • set_event_filter

    Register a callable to filter all canvas events before view dispatch.

Attributes:

Source code in src/scenex/model/_canvas.py
91
92
93
94
95
96
def __init__(
    self,
    *,
    views: Iterable[View] = (),
    **data: Unpack[CanvasKwargs],
) -> None: ...

size property writable #

size: tuple[int, int]

Return the size of the canvas.

close #

close() -> None

Close the canvas and release resources.

Source code in src/scenex/model/_canvas.py
108
109
110
111
def close(self) -> None:
    """Close the canvas and release resources."""
    for adaptor in self._get_adaptors():
        cast("CanvasAdaptor", adaptor)._snx_close()

content_rect_for #

content_rect_for(view: View) -> tuple[int, int, int, int]

The pixel rect (x, y, width, height) of the content area for a view.

Applies the view's padding, border_width, and margin insets to the outer rect returned by rect_for.

Source code in src/scenex/model/_canvas.py
121
122
123
124
125
126
127
128
129
130
def content_rect_for(self, view: View) -> tuple[int, int, int, int]:
    """The pixel rect (x, y, width, height) of the content area for a view.

    Applies the view's padding, border_width, and margin insets to the
    outer rect returned by ``rect_for``.
    """
    x, y, w, h = self.rect_for(view)
    layout = view.layout
    offset = int(layout.padding + layout.border_width + layout.margin)
    return (x + offset, y + offset, w - 2 * offset, h - 2 * offset)

filter_event #

filter_event(event: Event) -> bool

Pass event through the canvas-level filter, if any.

Returns True iff the event was handled and should not propagate.

Source code in src/scenex/model/_canvas.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def filter_event(self, event: Event) -> bool:
    """Pass *event* through the canvas-level filter, if any.

    Returns True iff the event was handled and should not propagate.
    """
    if self._filter:
        handled = self._filter(event)
        if not isinstance(handled, bool):
            logger.warning(
                f"Canvas event filter {self._filter} did not return a boolean. "
                "Returning False."
            )
            handled = False
        return handled
    return False

handle #

handle(event: Event) -> bool

Handle the passed event.

Source code in src/scenex/model/_canvas.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def handle(self, event: Event) -> bool:
    """Handle the passed event."""
    # 0. Handle events pertaining to the canvas model
    if isinstance(event, ResizeEvent):
        self.size = (event.width, event.height)

    # 1. Canvas-level filter sees all events first.
    if self.filter_event(event):
        return True

    # 2. Pass the event to the view under the mouse.
    # NOTE: Currently, only mouse events have a position. Maybe other events should
    # have them too?
    if isinstance(event, MouseEvent):
        # Find the view under the mouse, if any.
        current_view = self._containing_view(event.pos)

        # If that view is different from the last view...
        # TODO: Add a test for this once multiple views are better supported
        if self._last_mouse_view != current_view:
            # ...send a MouseLeaveEvent to the last view...
            if self._last_mouse_view is not None:
                self._last_mouse_view.filter_event(MouseLeaveEvent())
            # ...and a MouseEnterEvent to the new view (if the incoming event isn't
            # one already).
            if current_view is not None and not isinstance(event, MouseEnterEvent):
                current_view.filter_event(
                    MouseEnterEvent(pos=event.pos, buttons=event.buttons)
                )
        self._last_mouse_view = current_view

        if current_view is not None:
            # 2a. Give the view under the mouse the chance to handle the event.
            if current_view.filter_event(event):
                return True
            # 2b. If the view didn't handle the event, give any camera controller
            #    on the view the chance to handle it.
            if current_view.camera.interactive:
                if ctrl := current_view.camera.controller:
                    return ctrl.handle_event(event, current_view)

    # 3. MouseLeave events won't be on any view (because they have no position),
    #    so we need to handle them at the canvas level to clear the last_mouse_view.
    elif isinstance(event, MouseLeaveEvent):
        if self._last_mouse_view is not None:
            handled = self._last_mouse_view.filter_event(event)
            self._last_mouse_view = None
            return handled

    return False

model_post_init #

model_post_init(__context: Any) -> None

Post-initialization hook for the model.

Source code in src/scenex/model/_canvas.py
 98
 99
100
101
102
103
104
105
106
def model_post_init(self, __context: Any) -> None:
    """Post-initialization hook for the model."""
    # Update all current views
    for view in self.views:
        view.canvas = self

    self.views.item_inserted.connect(self._on_view_inserted)
    self.views.item_removed.connect(self._on_view_removed)
    self.views.item_changed.connect(self._on_view_changed)

rect_for #

rect_for(view: View) -> tuple[int, int, int, int]

The pixel rect (x, y, width, height) for a view, computed from its layout.

Source code in src/scenex/model/_canvas.py
113
114
115
116
117
118
119
def rect_for(self, view: View) -> tuple[int, int, int, int]:
    """The pixel rect (x, y, width, height) for a view, computed from its layout."""
    x = view.layout.x_start.resolve(self.width)
    w = view.layout.x_end.resolve(self.width) - x
    y = view.layout.y_start.resolve(self.height)
    h = view.layout.y_end.resolve(self.height) - y
    return (x, y, w, h)

render #

render() -> ndarray

Render the canvas to an image array.

Returns:

  • ndarray

    The rendered canvas as an RGBA image array. The array is expected to follow standard image conventions, with shape (height, width, 4).

Source code in src/scenex/model/_canvas.py
170
171
172
173
174
175
176
177
178
179
180
181
182
def render(self) -> np.ndarray:
    """Render the canvas to an image array.

    Returns
    -------
    np.ndarray
        The rendered canvas as an RGBA image array. The array is expected
        to follow standard image conventions, with shape
        ``(height, width, 4)``.
    """
    if adaptors := self._get_adaptors(create=True):
        return cast("CanvasAdaptor", adaptors[0])._snx_render()
    raise RuntimeError("No adaptor found for Canvas.")

set_event_filter #

set_event_filter(event_filter: Callable[[Event], bool] | None) -> Callable[[Event], bool] | None

Register a callable to filter all canvas events before view dispatch.

Parameters:

  • event_filter #

    (Callable[[Event], bool] | None) –

    A callable that takes an Event and returns True if the event was handled and should not be propagated further, False otherwise. Pass None to remove any existing filter.

Returns:

  • Callable[[Event], bool] | None

    The previous event filter, or None if there was no filter.

Source code in src/scenex/model/_canvas.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def set_event_filter(
    self, event_filter: Callable[[Event], bool] | None
) -> Callable[[Event], bool] | None:
    """Register a callable to filter all canvas events before view dispatch.

    Parameters
    ----------
    event_filter : Callable[[Event], bool] | None
        A callable that takes an Event and returns True if the event was handled
        and should not be propagated further, False otherwise. Pass None to remove
        any existing filter.

    Returns
    -------
    Callable[[Event], bool] | None
        The previous event filter, or None if there was no filter.
    """
    old, self._filter = self._filter, event_filter
    return old

ColorModel #

ColorModel(**data: Any)

Bases: BaseModel


              flowchart TD
              scenex.ColorModel[ColorModel]

              

              click scenex.ColorModel href "" "scenex.ColorModel"
            

Base class for color models used in scene nodes.

This class should not be instantiated directly. Instead, use one of its subclasses: - UniformColor: for a single color applied to the entire geometry - FaceColors: for per-face coloring (one color per face) - VertexColors: for per-vertex coloring (one color per vertex)

The color field is typed as Any to allow flexibility in subclasses.

Source code in src/scenex/model/_color.py
23
24
25
26
def __init__(self, **data: Any) -> None:
    if type(self) is ColorModel:
        raise TypeError("ColorModel cannot be instantiated directly")
    super().__init__(**data)

Coord #

Bases: BaseModel


              flowchart TD
              scenex.Coord[Coord]

              

              click scenex.Coord href "" "scenex.Coord"
            

Distance along a number of pixels. Expressed using CSS-style strings.

FaceColors #

FaceColors(**data: Any)

Bases: ColorModel


              flowchart TD
              scenex.FaceColors[FaceColors]
              scenex.model._color.ColorModel[ColorModel]

                              scenex.model._color.ColorModel --> scenex.FaceColors
                


              click scenex.FaceColors href "" "scenex.FaceColors"
              click scenex.model._color.ColorModel href "" "scenex.model._color.ColorModel"
            

Per-face coloring strategy for mesh-like nodes.

This model applies a different color to each face of a mesh. The color field is a sequence of Color instances, one for each face.

Examples:

Per-face coloring: >>> from cmap import Color >>> from scenex import FaceColors >>> model = FaceColors(color=[Color("red"), Color("blue"), Color("green")])

Source code in src/scenex/model/_color.py
23
24
25
26
def __init__(self, **data: Any) -> None:
    if type(self) is ColorModel:
        raise TypeError("ColorModel cannot be instantiated directly")
    super().__init__(**data)

Image #

Image(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Image[Image]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Image
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Image href "" "scenex.Image"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A 2D image rendered as a textured rectangle.

Image displays a 2D array of intensity values, mapping them to colors using a colormap. The image is rendered as a rectangle in 3D space, with pixels centered at integer coordinates starting from (0, 0). The image supports various rendering options including colormapping, intensity normalization, gamma correction, and interpolation.

The image's geometry spans from (-0.5, -0.5) to (width-0.5, height-0.5), meaning that pixel centers are at integer coordinates. This convention aligns with standard image processing practices.

Examples:

Create a simple grayscale image: >>> import numpy as np >>> data = np.random.rand(100, 100) >>> img = Image(data=data)

Create an image with custom colormap and intensity range: >>> img = Image(data=data, cmap=Colormap("viridis"), clims=(0, 255))

Create a transformed and semi-transparent image: >>> img = Image( ... data=data, ... transform=Transform().translated((10, 20)).scaled((2, 2)), ... opacity=0.7, ... )

Apply gamma correction to brighten dark images: >>> img = Image(data=data, gamma=0.5)

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

Layout #

Bases: EventedBase


              flowchart TD
              scenex.Layout[Layout]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._base.EventedBase --> scenex.Layout
                


              click scenex.Layout href "" "scenex.Layout"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

Style model for a view's border, padding, background, and placement.

Placement is defined by four independent strings — one for each edge of the view rect — resolved against the canvas size at render time via Canvas.rect_for. Accepted strings must follow CSS conventions:

  • "XX%" — a percentage of the canvas size along that axis
  • "XXpx" — a fixed pixel offset; negative values are measured from the far edge (right / bottom)

Examples:

Full canvas (default)::

Layout()

Fixed 400x300 region starting at (50, 50)::

Layout(x_start="50px", x_end="450px", y_start="50px", y_end="350px")

Left half, full height::

Layout(x_end="50%")
Notes

The layout follows this box model::

          x_start                          x_end
          |                                |
          v                                v
y_start-> +--------------------------------+
          |            margin              |
          |  +--------------------------+  |
          |  |         border           |  |
          |  |  +--------------------+  |  |
          |  |  |      padding       |  |  |
          |  |  |  +--------------+  |  |  |
          |  |  |  |   content    |  |  |  |
          |  |  |  |              |  |  |  |
          |  |  |  +--------------+  |  |  |
          |  |  +--------------------+  |  |
          |  +--------------------------+  |
  y_end-> +--------------------------------+

Methods:

Attributes:

x property writable #

The x-axis start/end as a tuple.

y property writable #

The y-axis start/end as a tuple.

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

Letterbox #

Bases: ResizePolicy


              flowchart TD
              scenex.Letterbox[Letterbox]
              scenex.model._view.ResizePolicy[ResizePolicy]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._view.ResizePolicy --> scenex.Letterbox
                                scenex.model._base.EventedBase --> scenex.model._view.ResizePolicy
                



              click scenex.Letterbox href "" "scenex.Letterbox"
              click scenex.model._view.ResizePolicy href "" "scenex.model._view.ResizePolicy"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

Maintain content aspect ratio on resize via letterboxing/pillarboxing.

The Letterbox strategy preserves the original aspect ratio of the camera's projection when the view is resized. When the view's aspect ratio differs from the content's aspect ratio, the projection is expanded in the narrower dimension to ensure all original content remains visible with black bars (letterboxing for wide views, pillarboxing for tall views).

The strategy tracks resize sequences (e.g., dragging a window corner) by storing the camera's projection as a reference at the start of that sequence. At any point during the sequence, the projection matrix is expanded in either width or height to retain the rectangle of that reference projection. A new sequence is defined by a change in the projection matrix, either programmatically made or through user input, signalled by a camera projection matrix different from that set during the last resize operation.

Examples:

Create a view with letterbox resizing: >>> from scenex.utils.projections import orthographic >>> view = View( ... camera=Camera(projection=orthographic(100, 100, 100)), ... on_resize=Letterbox(), ... )

When view is resized to 200x100 pixels, the projection expands horizontally to maintain the 1:1 aspect ratio, showing more content on the sides rather than stretching the image.

Notes

This approach follows the conventions of vispy's PanZoomCamera and pygfx's PerspectiveCamera. The projection matrix scales are inversely proportional to the displayed region: smaller scale values show more content.

See Also

ResizePolicy : Base class for resize policies View : View class that uses resize strategies Camera : Camera class with projection property

Methods:

  • handle_resize

    Handle view resize by adjusting projection to maintain aspect ratio.

  • model_post_init

    Called after the model is initialized.

handle_resize #

handle_resize(view: View) -> None

Handle view resize by adjusting projection to maintain aspect ratio.

Source code in src/scenex/model/_view.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
def handle_resize(self, view: View) -> None:
    """Handle view resize by adjusting projection to maintain aspect ratio."""
    # If the current projection differs from the last adjustment, or if there is no
    # reference to begin with, this is a new resize sequence.
    if view.camera.projection != self._last_adjustment or self._reference is None:
        self._reference = view.camera.projection

    if (view_rect := view.rect) is None or self._reference is None:
        # Nothing to do.
        return
    _, _, view_width, view_height = view_rect
    if view_height == 0:
        return

    # Extract projection scales that define the content aspect ratio
    ref_mat = self._reference.root
    ref_x_scale = ref_mat[0, 0]
    ref_y_scale = ref_mat[1, 1]
    if ref_y_scale == 0:
        return

    # Compute aspect ratios
    # NOTE: projection scales are inversely proportional to the displayed region,
    # so content_aspect = y_scale / x_scale
    view_aspect = view_width / view_height
    content_aspect = abs(ref_y_scale / ref_x_scale)

    # Expand the narrower dimension to match the view aspect
    if content_aspect < view_aspect:
        # View is wider: expand horizontal frustum (reduce x scale)
        adjusted_proj = self._reference.scaled(
            (content_aspect / view_aspect, 1.0, 1.0)
        )
    else:
        # View is taller: expand vertical frustum (reduce y scale)
        adjusted_proj = self._reference.scaled(
            (1.0, view_aspect / content_aspect, 1.0)
        )

    # Store the adjustment before applying it
    view.camera.projection = self._last_adjustment = adjusted_proj

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

Line #

Line(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Line[Line]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Line
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Line href "" "scenex.Line"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A polyline defined by connected vertices.

Line renders a sequence of connected line segments by drawing from each vertex to the next. The line can be colored uniformly or with per-vertex colors that smoothly interpolate along the path. Lines support width control and anti-aliasing for smooth rendering.

Vertices can be 2D or 3D coordinates. For 2D vertices, the z-coordinate is assumed to be 0, placing the line in the xy-plane.

Examples:

Create a simple line connecting several points: >>> import numpy as np >>> vertices = np.array([[0, 0], [10, 5], [20, 0]]) >>> line = Line( ... vertices=vertices, ... color=UniformColor(color=Color("red")), ... )

Create a line with per-vertex colors: >>> vertices = np.array([[0, 0], [10, 10], [20, 0]]) >>> colors = [Color("red"), Color("green"), Color("blue")] >>> line = Line( ... vertices=vertices, ... color=VertexColors(color=colors), ... width=2.0, ... )

Create a 3D line: >>> vertices = np.array([[0, 0, 0], [10, 5, 3], [20, 0, 6]]) >>> line = Line(vertices=vertices, width=3.0)

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • passes_through

    Check if the ray passes through this line.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

passes_through #

passes_through(ray: Ray) -> float | None

Check if the ray passes through this line.

Parameters:

  • ray #

    (Ray) –

    The ray to test for intersection.

Returns:

  • float | None

    The distance to the closest intersection, or None if no intersection.

Source code in src/scenex/model/_nodes/line.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def passes_through(self, ray: Ray) -> float | None:
    """
    Check if the ray passes through this line.

    Parameters
    ----------
    ray : Ray
        The ray to test for intersection.

    Returns
    -------
    float | None
        The distance to the closest intersection, or None if no intersection.
    """
    verts = np.asarray(self.vertices)
    # Convert vertices to canvas space
    canvas_vertices = self._node_to_canvas(ray.source)
    # Convert ray to canvas space
    canvas_ray = Line._world_to_canvas(ray, np.array([ray.origin]))[0]

    starts = canvas_vertices[:-1]
    ends = canvas_vertices[1:]

    # Compute the distance from the ray ON THE CANVAS to the closest point the line
    # associated with each line segment.
    #
    # Equation loaned from https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
    num = np.abs(
        (ends[:, 1] - starts[:, 1]) * canvas_ray[0]
        - (ends[:, 0] - starts[:, 0]) * canvas_ray[1]
        + ends[:, 0] * starts[:, 1]
        - ends[:, 1] * starts[:, 0]
    )
    den = np.sqrt(
        (ends[:, 1] - starts[:, 1]) ** 2 + (ends[:, 0] - starts[:, 0]) ** 2
    )
    den[den == 0] = float("inf")  # Avoid division by zero
    distance = num / den

    # Determine the corresponding point in world space corresponding to that closest
    # point. Note that this point is only on the line segment if 0 <= t <= 1.
    # (We check this at the end.)
    a = np.subtract(canvas_ray, starts)
    b = np.subtract(ends, starts)
    # Vectorized version of dot product
    t = np.sum(a * b, axis=1) / np.sum(b * b, axis=1)
    intersect_world = verts[1:] + t[:, np.newaxis] * (verts[:-1] - verts[1:])

    # Calculate the distance along the ray to the intersection point
    # The ray is defined as: ray.origin + d * ray.direction
    # We need to find d such that ray.origin + d * ray.direction = intersect_world
    # This gives us: d * ray.direction = intersect_world - ray.origin
    # Solving for d: d = dot(intersect_world - ray.origin, ray.direction) /
    #                     dot(ray.direction, ray.direction)
    ray_to_intersect = np.subtract(intersect_world, ray.origin)
    ray_dir_squared = np.dot(ray.direction, ray.direction)

    if ray_dir_squared == 0:
        return None  # Degenerate ray

    d = np.dot(ray_to_intersect, ray.direction) / ray_dir_squared

    # Our ray intersects the line if:
    # 1. The distance from the ray to the line is less than the line width
    # 2. The intersection point is within the line segment (0 <= t <= 1)
    # 3. The intersection point is in front of the ray origin (d >= 0)
    condition = (distance <= self.width) & (t >= 0) & (t <= 1) & (d >= 0)
    valid_intersections = d[condition]
    if len(valid_intersections):
        return float(np.min(valid_intersections))
    else:
        return None

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

Mesh #

Mesh(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Mesh[Mesh]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Mesh
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Mesh href "" "scenex.Mesh"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A 3D surface mesh composed of triangular faces.

Mesh represents a 3D surface defined by vertices and triangular faces. Each face is specified by three indices into the vertex array, forming a triangle. The mesh uses counter-clockwise winding order: for a face (v1, v2, v3), the normal vector points in the direction of (v2 - v1) x (v3 - v1).

Meshes support ray-triangle intersection testing using the Möller-Trumbore algorithm, enabling efficient picking and interaction.

Examples:

Create a simple triangle mesh: >>> import numpy as np >>> vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) >>> faces = np.array([[0, 1, 2]]) >>> mesh = Mesh( ... vertices=vertices, ... faces=faces, ... color=UniformColor(color=Color("blue")), ... )

Create a square made of two triangles: >>> vertices = np.array( ... [ ... [0, 0, 0], # bottom-left ... [1, 0, 0], # bottom-right ... [1, 1, 0], # top-right ... [0, 1, 0], # top-left ... ] ... ) >>> faces = np.array( ... [ ... [0, 1, 2], # first triangle ... [0, 2, 3], # second triangle ... ] ... ) >>> mesh = Mesh(vertices=vertices, faces=faces)

Notes

Face winding order (counter-clockwise) determines which side of the triangle is considered the "front" face. The normal vector for face (v1, v2, v3) points in the direction of (v2 - v1) x (v3 - v1).

Methods:

  • add_child

    Add a child node to this node.

  • intersecting_faces

    Find all faces that intersect with the given ray.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • passes_through

    Check if the ray passes through this mesh and return the closest distance.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

intersecting_faces #

intersecting_faces(ray: Ray) -> list[tuple[int, float]]

Find all faces that intersect with the given ray.

Uses the Möller-Trumbore intersection algorithm, vectorized over all triangles. Adapted from https://en.wikipedia.org/wiki/M%C3%B6ller%E2%80%93Trumbore_intersection_algorithm#C++_implementation

Parameters:

  • ray #

    (Ray) –

    The ray to test for intersections.

Returns:

  • list[tuple[int, float]]

    A list of tuples containing (face_index, distance) for each intersecting face. Sorted by distance (closest first).

Source code in src/scenex/model/_nodes/mesh.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def intersecting_faces(self, ray: Ray) -> list[tuple[int, float]]:
    """
    Find all faces that intersect with the given ray.

    Uses the Möller-Trumbore intersection algorithm, vectorized over all triangles.
    Adapted from https://en.wikipedia.org/wiki/M%C3%B6ller%E2%80%93Trumbore_intersection_algorithm#C++_implementation

    Parameters
    ----------
    ray : Ray
        The ray to test for intersections.

    Returns
    -------
    list[tuple[int, float]]
        A list of tuples containing (face_index, distance) for each
        intersecting face. Sorted by distance (closest first).
    """
    tracked_faces = np.arange(len(self.faces))

    # Suppose the triangle is defined by vertices v1, v2, v3
    # Barycentric coordinates are given by
    # P = (1 - u - v)*v1 + u*v2 + v*v3,
    #   = v1 + u(v2 - v1) + v(v3 - v1)
    #   = v1 + u*e1 + v*e2
    # But the intersection point is also given by our ray equation:
    # P = ray.origin + t*ray.direction
    # So we need to solve the equation
    # ray.origin + t*ray.direction = v1 + u*e1 + v*e2
    # Rearranging:
    # ray.origin - v1 = -t*ray.direction + u*e1 + v*e2
    e1 = self.vertices[self.faces[:, 1]] - self.vertices[self.faces[:, 0]]
    e2 = self.vertices[self.faces[:, 2]] - self.vertices[self.faces[:, 0]]

    # First, cull all triangles parallel to the ray
    # We compute the determinant (scalar triple product) for this
    ray_cross_e2 = np.cross(ray.direction, e2)
    # NOTE: Vectorized version of row-wise dot product of ray_cross_e2 and e1
    det = np.sum(ray_cross_e2 * e1, axis=1)
    parallel_triangles = np.isclose(det, 0)

    # Remove parallel triangles from consideration
    e1 = e1[~parallel_triangles]
    e2 = e2[~parallel_triangles]
    ray_cross_e2 = ray_cross_e2[~parallel_triangles]
    det = det[~parallel_triangles]
    v1 = self.vertices[self.faces[:, 0]][~parallel_triangles]
    tracked_faces = tracked_faces[~parallel_triangles]

    # We can use Cramer's Rule to solve for t, u, v
    # (We solve for u, v first to check if the intersection is within the triangle)
    #
    # u = (1/det) * scalar_triple_product(ray.direction, s, e2)
    inv_det = 1 / det
    s = ray.origin - v1
    u = inv_det * np.sum(s * ray_cross_e2, axis=1)
    # v = (1/det) * scalar_triple_product(ray.direction, e1, s)
    s_cross_e1 = np.cross(s, e1)
    v = inv_det * np.sum(ray.direction * s_cross_e1, axis=1)

    # Cull triangles where the intersection is outside the triangle
    intersecting = (u >= 0) & (v >= 0) & (u + v < 1)

    if not np.any(intersecting):
        return []

    # Get the indices and data for intersecting triangles
    tracked_faces = tracked_faces[intersecting]
    inv_det = inv_det[intersecting]
    e2 = e2[intersecting]
    s_cross_e1 = s_cross_e1[intersecting]

    # t = (1/det) * scalar_triple_product(s, e1, e2)
    t = inv_det * np.sum(e2 * s_cross_e1, axis=1)

    # Create list of (face_index, distance) tuples and sort by distance
    intersections = list(zip(tracked_faces, t, strict=True))
    intersections.sort(key=lambda x: x[1])  # Sort by distance

    return intersections

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

passes_through #

passes_through(ray: Ray) -> float | None

Check if the ray passes through this mesh and return the closest distance.

Parameters:

  • ray #

    (Ray) –

    The ray to test for intersection.

Returns:

  • float | None

    The distance to the closest intersection, or None if no intersection.

Source code in src/scenex/model/_nodes/mesh.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def passes_through(self, ray: Ray) -> float | None:
    """
    Check if the ray passes through this mesh and return the closest distance.

    Parameters
    ----------
    ray : Ray
        The ray to test for intersection.

    Returns
    -------
    float | None
        The distance to the closest intersection, or None if no intersection.
    """
    intersections = self.intersecting_faces(ray)
    if not intersections:
        return None

    # Return the closest intersection distance
    return float(intersections[0][1])

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

Node #

Node(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: EventedBase


              flowchart TD
              scenex.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._base.EventedBase --> scenex.Node
                


              click scenex.Node href "" "scenex.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

Base class for all nodes in the scene graph.

Node is the fundamental building block of scenex's scene graph architecture. Nodes form a hierarchical tree structure where each node can have a parent and children, creating a parent-child relationship that propagates transformations, visibility, and other properties through the graph.

Nodes are abstract and should not be instantiated directly. Use concrete subclasses like Image, Points, Line, Mesh, Scene, or Camera instead.

The scene graph hierarchy allows: - Hierarchical transformations: A node's transform is relative to its parent - Property inheritance: Visibility and opacity affect all descendants - Spatial relationships: Nodes can find paths to other nodes in the graph - Event handling: Interactive nodes can respond to user input

Notes

Do not instantiate Node directly. Use concrete subclasses instead.

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • passes_through

    Returns the depth t at which the provided ray intersects this node.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

passes_through #

passes_through(ray: Ray) -> float | None

Returns the depth t at which the provided ray intersects this node.

The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t, where t>=0

Parameters:

  • ray #

    (Ray) –

    The ray passing through the scene

Returns:

  • t ( float | None ) –

    The depth t at which the ray intersects the node, or None if it never intersects.

Source code in src/scenex/model/_nodes/node.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def passes_through(self, ray: Ray) -> float | None:
    """Returns the depth t at which the provided ray intersects this node.

    The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t,
    where t>=0

    Parameters
    ----------
    ray : Ray
        The ray passing through the scene

    Returns
    -------
    t: float | None
        The depth t at which the ray intersects the node, or None if it never
        intersects.
    """
    # Nodes that want to support ray intersection should override this method.
    return None

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

Orbit #

Bases: CameraController


              flowchart TD
              scenex.Orbit[Orbit]
              scenex.model._nodes.camera.CameraController[CameraController]

                              scenex.model._nodes.camera.CameraController --> scenex.Orbit
                


              click scenex.Orbit href "" "scenex.Orbit"
              click scenex.model._nodes.camera.CameraController href "" "scenex.model._nodes.camera.CameraController"
            

3D orbit controller for rotating around a focal point.

Orbit provides intuitive 3D navigation for perspective views by allowing the camera to rotate around a fixed center point while maintaining its distance.

The strategy uses spherical coordinates to control camera position: - Azimuth: Rotation around the polar axis (typically Z), controlling left/right movement around the scene - Elevation: Angle from the polar axis, controlling up/down viewing angle - Distance: Radius from the center point, controlled by zooming

During rotation, foreground objects (between the camera and the center) move in the direction of mouse movement, providing intuitive control where the visible scene appears to rotate under the mouse.

The right mouse button allows panning the orbit center itself, enabling exploration of large scenes by moving the focal point while maintaining the camera's viewing angle and distance.

Attributes:

  • center (tuple[float, float, float]) –

    The point in 3D space around which the camera orbits. This is the focal point that remains stationary during rotation. Default is (0, 0, 0).

  • polar_axis (tuple[float, float, float]) –

    The axis defining the "up" direction for orbit calculations. Azimuth rotations occur around this axis. Default is (0, 0, 1) for Z-up orientation.

Examples:

Orbit around the origin: >>> import scenex as snx >>> from scenex.utils import projections >>> # Create a perspective camera... >>> camera = snx.Camera( ... interactive=True, ... projection=projections.perspective(fov=70, near=1, far=1000), ... ) >>> # ...positioned along the X axis... >>> camera.transform = snx.Transform().translated((100, 0, 0)) >>> # ...looking at the origin... >>> camera.look_at((0, 0, 0), up=(0, 0, 1)) >>> # ...that orbits around the origin >>> camera.controller = snx.Orbit(center=(0, 0, 0))

Orbit around a data volume's center: >>> import numpy as np >>> my_data = np.random.rand(100, 100, 100).astype(np.float32) >>> volume = snx.Volume(data=my_data) >>> center = np.mean(volume.bounding_box, axis=0) >>> # Create a perspective camera... >>> camera = snx.Camera( ... interactive=True, ... projection=projections.perspective(fov=70, near=1, far=1000), ... ) >>> # ...positioned along the X axis from the volume center... >>> camera.transform = ( ... snx.Transform().translated(center).translated((100, 0, 0)) ... ) >>> # ...looking at the center... >>> camera.look_at(center, up=(0, 0, 1)) >>> # ...that orbits around the center >>> camera.controller = snx.Orbit(center=center)

Custom polar axis for Y-up scenes: >>> camera = snx.Camera( ... controller=snx.Orbit(center=(0, 0, 0), polar_axis=(0, 1, 0)), ... interactive=True, ... )

Interactions
  • Left mouse drag: Orbit/rotate the camera around the center point
  • Right mouse drag: Pan the orbit center (translates the focal point)
  • Mouse wheel: Zoom toward/away from center (change radius)
Notes

Elevation is automatically clamped to [0°, 180°] to prevent the camera from going upside down. Without this clamping, the camera could rotate past the polar axis, causing horizontal mouse movement to make the foreground rotate in the opposite direction to the actual mouse movement.

See Also

PanZoom : 2D pan and zoom controller for orthographic views CameraController : Base class for camera controllers Camera : Camera class with controller field Camera.look_at : Method to orient camera toward a point

Methods:

  • handle_event

    Handle mouse and wheel events to orbit the camera.

handle_event #

handle_event(event: Event, view: View) -> bool

Handle mouse and wheel events to orbit the camera.

Source code in src/scenex/model/_nodes/camera.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
def handle_event(self, event: Event, view: View) -> bool:
    """Handle mouse and wheel events to orbit the camera."""
    if not view.camera.interactive:
        return False

    handled = False
    center_array = np.asarray(self.center)

    if not isinstance(event, MouseEvent):
        return False
    if (ray := view.to_ray(event.pos)) is None:
        return False
    # Orbit on mouse move with left button held
    if (
        isinstance(event, MouseMoveEvent)
        and event.buttons == MouseButton.LEFT
        and self._last_canvas_pos is not None
    ):
        # The process of orbiting is as follows:
        # 1. Compute the azimuth and elevation changes based on mouse movement.
        #   - Azimuth describes the angle between the the positive X axis and
        #       the projection of the camera's position onto the XY plane.
        #   - Elevation describes the angle between the camera's position and
        #       the positive Z axis.
        # 2. Ensure these changes are clamped to valid ranges (only really
        #   applies to elevation).
        # 3. Adjust the current transform by:
        #   a. Translating by the negative of the centerpoint, to take it out of
        #       the computation.
        #   b. Rotating to adjust the elevation. The axis of rotation is defined
        #       by the camera's right vector. Note that this is done before the
        #       azimuth adjustment because that adjustment will alter the
        #       camera's right vector.
        #   c. Rotating to adjust the azimuth. The axis of rotation is always
        #       the positive Z axis.
        #   d. Translating by the centerpoint, to reorient the camera around
        #           that centerpoint.

        # Step 0: Gather transform components, relative to camera center
        orbit_mat = view.camera.transform.translated(-center_array)
        position, _rotation, _scale = la.mat_decompose(orbit_mat.T)
        camera_right = np.cross(view.camera.forward, view.camera.up)

        # Step 1
        d_azimuth = self._last_canvas_pos[0] - event.pos[0]
        d_elevation = self._last_canvas_pos[1] - event.pos[1]

        # Step 2
        e_bound = float(la.vec_angle(position, (0, 0, 1)) * 180 / math.pi)
        if e_bound + d_elevation < 0:
            d_elevation = -e_bound
        if e_bound + d_elevation > 180:
            d_elevation = 180 - e_bound

        # Step 3
        view.camera.transform = (
            view.camera.transform.translated(-center_array)  # 3a
            .rotated(d_elevation, camera_right)  # 3b
            .rotated(d_azimuth, self.polar_axis)  # 3c
            .translated(center_array)  # 3d
        )

        handled = True

    # Pan on mouse press with right button
    elif isinstance(event, MousePressEvent) and event.buttons == MouseButton.RIGHT:
        self._pan_ray = ray

    # Pan on mouse move with right button held
    elif (
        isinstance(event, MouseMoveEvent)
        and event.buttons == MouseButton.RIGHT
        and self._pan_ray is not None
    ):
        dr = np.linalg.norm(view.camera.transform.map((0, 0, 0))[:3] - center_array)
        old_center = self._pan_ray.origin[:3] + np.multiply(
            dr, self._pan_ray.direction
        )
        new_center = ray.origin[:3] + np.multiply(dr, ray.direction)
        diff = np.subtract(old_center, new_center)
        view.camera.transform = view.camera.transform.translated(diff)
        # Update the center
        new_center_array = center_array + diff
        new_center_tuple = (
            float(new_center_array[0]),
            float(new_center_array[1]),
            float(new_center_array[2]),
        )
        self.center = new_center_tuple
        handled = True

    elif isinstance(event, WheelEvent):
        _dx, dy = event.angle_delta
        if dy:
            dr = view.camera.transform.map((0, 0, 0))[:3] - center_array
            zoom = self._zoom_factor(dy)
            view.camera.transform = view.camera.transform.translated(
                dr * (zoom - 1)
            )
        handled = True

    if isinstance(event, MouseEvent):
        self._last_canvas_pos = event.pos
    return handled

PanZoom #

Bases: CameraController


              flowchart TD
              scenex.PanZoom[PanZoom]
              scenex.model._nodes.camera.CameraController[CameraController]

                              scenex.model._nodes.camera.CameraController --> scenex.PanZoom
                


              click scenex.PanZoom href "" "scenex.PanZoom"
              click scenex.model._nodes.camera.CameraController href "" "scenex.model._nodes.camera.CameraController"
            

2D pan and zoom controller for orthographic views.

PanZoom provides intuitive mouse-based navigation for 2D scenes and orthographic projections.

The strategy operates in two complementary ways: - Panning (left mouse drag): Modifies camera.transform to translate the camera position, maintaining the scene coordinates under the cursor. - Zooming (mouse wheel): Modifies camera.projection to scale the view, then adjusts camera.transform to keep the zoom centered on the cursor position.

Optional axis locking allows constraining interaction to horizontal or vertical movement only

Attributes:

  • lock_x (bool) –

    If True, prevent horizontal panning and zooming. Movement is constrained to the vertical axis only. Default is False.

  • lock_y (bool) –

    If True, prevent vertical panning and zooming. Movement is constrained to the horizontal axis only. Default is False.

Examples:

Standard 2D pan and zoom: >>> camera = Camera(controller=PanZoom(), interactive=True)

Lock horizontal movement for vertical scrolling only: >>> camera = Camera(controller=PanZoom(lock_x=True), interactive=True)

Create an image viewer with pan/zoom: >>> import numpy as np >>> import scenex as snx >>> from scenex.utils import projections >>> my_data = np.random.rand(512, 512).astype(np.float32) >>> view = snx.View( ... scene=snx.Scene(children=[snx.Image(data=my_data)]), ... camera=snx.Camera( ... controller=snx.PanZoom(), ... interactive=True, ... ), ... ) >>> projections.zoom_to_fit(view=view, type="orthographic")

See Also

Orbit : 3D orbit controller for perspective views CameraController : Base class for camera controllers Camera : Camera class with controller field

Methods:

  • handle_event

    Handle mouse and wheel events to pan/zoom the camera.

handle_event #

handle_event(event: Event, view: View) -> bool

Handle mouse and wheel events to pan/zoom the camera.

Source code in src/scenex/model/_nodes/camera.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def handle_event(self, event: Event, view: View) -> bool:
    """Handle mouse and wheel events to pan/zoom the camera."""
    if not view.camera.interactive:
        return False

    handled = False

    if not isinstance(event, MouseEvent):
        return False
    if (ray := view.to_ray(event.pos)) is None:
        return False
    # Panning involves keeping a particular position underneath the cursor.
    # That position is recorded on a left mouse button press.
    if isinstance(event, MousePressEvent) and MouseButton.LEFT in event.buttons:
        self._drag_pos = ray.origin[:2]
    # Every time the cursor is moved, until the left mouse button is released,
    # We translate the camera such that the position is back under the cursor
    # (i.e. under the world ray origin)
    elif (
        isinstance(event, MouseMoveEvent)
        and MouseButton.LEFT in event.buttons
        and self._drag_pos
    ):
        new_pos = ray.origin[:2]
        dx = self._drag_pos[0] - new_pos[0]
        if not self.lock_x:
            view.camera.transform = view.camera.transform.translated((dx, 0))
        dy = self._drag_pos[1] - new_pos[1]
        if not self.lock_y:
            view.camera.transform = view.camera.transform.translated((0, dy))
        handled = True

    # Note that while panning adjusts the camera's transform matrix, zooming
    # adjusts the projection matrix.
    elif isinstance(event, WheelEvent):
        # Zoom while keeping the position under the cursor fixed.
        _dx, dy = event.angle_delta
        if dy:
            # Step 1: Adjust the projection matrix to zoom in or out.
            zoom = self._zoom_factor(dy)
            view.camera.projection = view.camera.projection.scaled(
                (1 if self.lock_x else zoom, 1 if self.lock_y else zoom, 1.0)
            )

            # Step 2: Adjust the transform matrix to maintain the position
            # under the cursor. The math is largely borrowed from
            # https://github.com/pygfx/pygfx/blob/520af2d5bb2038ec309ef645e4a60d502f00d181/pygfx/controllers/_panzoom.py#L164

            # Find the distance between the world ray and the camera
            zoom_center = np.asarray(ray.origin)[:2]
            camera_center = np.asarray(view.camera.transform.map((0, 0)))[:2]
            # Compute the world distance before the zoom
            delta_screen1 = zoom_center - camera_center
            # Compute the world distance after the zoom
            delta_screen2 = delta_screen1 * zoom
            # The pan is the difference between the two
            pan = (delta_screen2 - delta_screen1) / zoom
            view.camera.transform = view.camera.transform.translated(
                (
                    pan[0] if not self.lock_x else 0,
                    pan[1] if not self.lock_y else 0,
                )
            )
            handled = True

    return handled

Points #

Points(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Points[Points]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Points
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Points href "" "scenex.Points"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A collection of point markers rendered at specified coordinates.

Points displays symbols (markers) at 2D or 3D coordinates in the scene. Each point is rendered using a specified symbol shape (disc, square, star, etc.) with customizable size, face color, and edge styling. Points support different scaling modes to control whether they maintain constant screen size or scale with the scene.

Examples:

Create simple point markers: >>> import numpy as np >>> vertices = np.random.rand(100, 2) * 100 >>> points = Points( ... vertices=vertices, ... size=5, ... face_color=UniformColor(color=Color("red")), ... )

Create points with custom symbols and styling: >>> points = Points( ... vertices=vertices, ... symbol="star", ... size=20, ... face_color=UniformColor(color=Color("yellow")), ... edge_color=UniformColor(color=Color("orange")), ... edge_width=2, ... )

Create fixed-size points that don't scale with zoom: >>> points = Points( ... vertices=vertices, ... size=10, ... scaling="fixed", ... face_color=UniformColor(color=Color("blue")), ... )

Create 3D points: >>> vertices_3d = np.random.rand(50, 3) * 100 >>> points = Points(vertices=vertices_3d, symbol="diamond", size=15)

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

ResizePolicy #

Bases: EventedBase


              flowchart TD
              scenex.ResizePolicy[ResizePolicy]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._base.EventedBase --> scenex.ResizePolicy
                


              click scenex.ResizePolicy href "" "scenex.ResizePolicy"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

Base class defining how a view adapts to changes in its layout dimensions.

A ResizePolicy is invoked automatically when a view's layout dimensions change, providing a hook to adjust any aspect of the view in response. While the most common use case is adjusting the camera's projection matrix to maintain aspect ratio or fit content, strategies have full access to the view and can modify the camera, scene, layout, or any other properties as needed.

Strategies are attached to View instances and called whenever the layout width or height changes, whether from user interaction (window resize, splitter drag) or programmatic updates.

Examples:

Maintain aspect ratio when view resizes: >>> view = View(camera=Camera(), on_resize=Letterbox())

No resize behavior (omit the on_resize parameter): >>> view = View(camera=Camera())

See Also

Letterbox : Resize strategy that maintains aspect ratio View : View class that uses resize strategies Camera : Camera class with projection property

Methods:

handle_resize abstractmethod #

handle_resize(view: View) -> None

Respond to view layout dimension changes.

This method is called automatically when the view's layout dimensions change. Implementations have full access to the view and can modify any of its properties.

Parameters:

  • view #

    (View) –

    The view being resized.

Source code in src/scenex/model/_view.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
@abstractmethod
def handle_resize(self, view: View) -> None:
    """
    Respond to view layout dimension changes.

    This method is called automatically when the view's layout dimensions change.
    Implementations have full access to the view and can modify any of its
    properties.

    Parameters
    ----------
    view : View
        The view being resized.
    """
    ...

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

Scene #

Scene(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Scene[Scene]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Scene
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Scene href "" "scenex.Scene"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

The root container node for a scene graph.

Scene is a specialized Node that serves as the root of a scene graph hierarchy. It contains all the visual elements (Images, Points, Lines, Meshes, etc.) and cameras that make up a complete 3D scene. While functionally identical to a Node, Scene provides semantic clarity that this is the top-level container.

A Scene is typically associated with a View, which pairs it with a Camera to define what is rendered and how. Multiple views can display the same scene from different camera perspectives.

Examples:

Create a scene with visual elements: >>> import numpy as np >>> my_image = np.random.rand(100, 100).astype(np.float32) >>> my_points = np.random.rand(100, 3).astype(np.float32) >>> scene = Scene( ... children=[ ... Image(data=my_image), ... Points( ... vertices=my_points, ... face_color=UniformColor(color=Color("red")), ... ), ... ] ... )

Create an empty scene and later add children: >>> scene = Scene() >>> scene.add_child(Image(data=my_image)) >>> scene.add_child(Points(vertices=my_points))

Create a hierarchical scene with nested nodes: >>> grandchild = Image(data=my_image) >>> parent = Points(vertices=my_points) >>> scene = Scene(children=[parent])

Use a scene with a view: >>> view = View(scene=scene, camera=Camera()) >>> canvas = Canvas(views=[view])

Notes

Scene inherits all Node attributes and methods including transform, visible, opacity, and children management. The scene itself does not have visual representation; it only serves as a container for renderable nodes.

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • passes_through

    Returns the depth t at which the provided ray intersects this node.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/scene.py
67
68
69
70
71
72
def __init__(
    self,
    *,
    children: Iterable["Node | dict[str, Any]"] = (),
    **data: "Unpack[NodeKwargs]",
) -> None: ...

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

passes_through #

passes_through(ray: Ray) -> float | None

Returns the depth t at which the provided ray intersects this node.

The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t, where t>=0

Parameters:

  • ray #

    (Ray) –

    The ray passing through the scene

Returns:

  • t ( float | None ) –

    The depth t at which the ray intersects the node, or None if it never intersects.

Source code in src/scenex/model/_nodes/node.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def passes_through(self, ray: Ray) -> float | None:
    """Returns the depth t at which the provided ray intersects this node.

    The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t,
    where t>=0

    Parameters
    ----------
    ray : Ray
        The ray passing through the scene

    Returns
    -------
    t: float | None
        The depth t at which the ray intersects the node, or None if it never
        intersects.
    """
    # Nodes that want to support ray intersection should override this method.
    return None

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

Text #

Text(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Node


              flowchart TD
              scenex.Text[Text]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.node.Node --> scenex.Text
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                



              click scenex.Text href "" "scenex.Text"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A text label positioned in 3D world space.

The text maintains a constant screen size regardless of camera zoom or distance, making it useful for labels, annotations, and markers. The text is positioned at the node's transformed origin point.

Examples:

Create a simple text label: >>> text = Text(text="Hello World", color=Color("white"), size=14)

Notes

Text maintains constant screen size, not world size. The font size is specified in pixels and does not scale with camera zoom or distance from the viewer.

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • passes_through

    Returns the depth t at which the provided ray intersects this node.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

passes_through #

passes_through(ray: Ray) -> float | None

Returns the depth t at which the provided ray intersects this node.

The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t, where t>=0

Parameters:

  • ray #

    (Ray) –

    The ray passing through the scene

Returns:

  • t ( float | None ) –

    The depth t at which the ray intersects the node, or None if it never intersects.

Source code in src/scenex/model/_nodes/node.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def passes_through(self, ray: Ray) -> float | None:
    """Returns the depth t at which the provided ray intersects this node.

    The ray, in this case, is defined by R(t) = ray_origin + ray_direction * t,
    where t>=0

    Parameters
    ----------
    ray : Ray
        The ray passing through the scene

    Returns
    -------
    t: float | None
        The depth t at which the ray intersects the node, or None if it never
        intersects.
    """
    # Nodes that want to support ray intersection should override this method.
    return None

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

Transform #

Bases: RootModel


              flowchart TD
              scenex.Transform[Transform]

              

              click scenex.Transform href "" "scenex.Transform"
            

A 4x4 homogeneous transformation matrix for 3D affine transformations.

Transformations use homogeneous coordinates, where 3D points (x, y, z) are represented as 4-vectors (x, y, z, 1). This enables affine transformations (translation, rotation, scaling) to be represented as matrix multiplication.

The Transform class is immutable (frozen). Operations like translated(), rotated(), and scaled() return new Transform instances rather than modifying the original.

Examples:

Create an identity transform: >>> transform = Transform()

Translate an object: >>> transform = Transform().translated((10, 20, 30))

Rotate 45 degrees around the z-axis: >>> transform = Transform().rotated(45, axis=(0, 0, 1))

Scale uniformly by 2x: >>> transform = Transform().scaled((2, 2, 2))

Chain multiple transformations: >>> transform = ( ... Transform() ... .translated((10, 0, 0)) ... .rotated(45, (0, 0, 1)) ... .scaled((2, 2, 2)) ... )

Rotate around a specific point: >>> transform = Transform().rotated(90, axis=(0, 0, 1), about=(10, 10, 0))

Transform coordinates: >>> points = np.array([[0, 0, 0], [1, 1, 1]]) >>> transformed = transform.map(points)

Combine two transforms: >>> transform1 = Transform().translated((5, 0, 0)) >>> transform2 = Transform().scaled((2, 2, 2)) >>> combined = transform1 @ transform2

Invert a transform: >>> inverse = transform.inv()

Notes
  • Transformations are applied in the order they are chained
  • The transform is immutable; all operations return new instances
  • Uses right-multiplication convention: point @ matrix
  • Default rotation axis is (0, 0, 1) - the z-axis

Methods:

  • chain

    Chain multiple transforms together.

  • dot

    Return the dot product of this transform with another.

  • imap

    Inverse map coordinates.

  • inv

    Return the inverse of the transform.

  • is_null

    Return True if the transform is the identity matrix.

  • map

    Map coordinates.

  • rotated

    Return new transform, rotated some angle about a given axis.

  • scaled

    Return new transform, scaled about a given origin.

  • translated

    Return new transform, translated by pos.

Attributes:

  • T (Transform) –

    Return the transpose of the transform.

T property #

Return the transpose of the transform.

chain classmethod #

chain(*transforms: Transform) -> Transform

Chain multiple transforms together.

Parameters:

  • transforms #

    (Transform, default: () ) –

    Transforms to chain.

Returns:

  • transform ( Transform ) –

    Chained transform.

Source code in src/scenex/model/_transform.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
@classmethod
def chain(cls, *transforms: Transform) -> Transform:
    """Chain multiple transforms together.

    Parameters
    ----------
    transforms : Transform
        Transforms to chain.

    Returns
    -------
    transform : Transform
        Chained transform.
    """
    return reduce(lambda a, b: a @ b, transforms, cls())

dot #

dot(other: Transform | ArrayLike) -> Transform

Return the dot product of this transform with another.

Source code in src/scenex/model/_transform.py
173
174
175
176
177
def dot(self, other: Transform | ArrayLike) -> Transform:
    """Return the dot product of this transform with another."""
    if isinstance(other, Transform):
        other = other.root
    return Transform(np.dot(self.root, other))

imap #

Inverse map coordinates.

Parameters:

  • coords #

    (array - like) –

    Coordinates to inverse map.

Returns:

  • coords ( ndarray ) –

    Coordinates.

Source code in src/scenex/model/_transform.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
@_arg_to_vec4
def imap(self, coords: ArrayLike) -> NDArray:
    """Inverse map coordinates.

    Parameters
    ----------
    coords : array-like
        Coordinates to inverse map.

    Returns
    -------
    coords : ndarray
        Coordinates.
    """
    return cast("NDArray", np.dot(coords, np.linalg.inv(self.root)))

inv #

inv() -> Transform

Return the inverse of the transform.

Source code in src/scenex/model/_transform.py
184
185
186
def inv(self) -> Transform:
    """Return the inverse of the transform."""
    return Transform(np.linalg.inv(self.root))

is_null #

is_null() -> bool

Return True if the transform is the identity matrix.

Source code in src/scenex/model/_transform.py
163
164
165
def is_null(self) -> bool:
    """Return True if the transform is the identity matrix."""
    return np.allclose(self.root, np.eye(4))

map #

Map coordinates.

Parameters:

  • coords #

    (array - like) –

    Coordinates to map.

Returns:

  • coords ( ndarray ) –

    Coordinates.

Source code in src/scenex/model/_transform.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
@_arg_to_vec4
def map(self, coords: ArrayLike) -> NDArray:
    """Map coordinates.

    Parameters
    ----------
    coords : array-like
        Coordinates to map.

    Returns
    -------
    coords : ndarray
        Coordinates.
    """
    # looks backwards, but both matrices are transposed.
    return cast("NDArray", np.dot(coords, self.root))

rotated #

rotated(angle: float, axis: ArrayLike = (0, 0, 1), about: ArrayLike | None = None) -> Transform

Return new transform, rotated some angle about a given axis.

The rotation is applied after the transformations already present in the matrix.

Parameters:

  • angle #

    (float) –

    The angle of rotation, in degrees.

  • axis #

    (array - like, default: (0, 0, 1) ) –

    The x, y and z coordinates of the axis vector to rotate around. By default, will rotate around the z-axis: (0, 0, 1).

  • about #

    (array - like or None, default: None ) –

    The x, y and z coordinates to rotate around. If None, will rotate around the origin (0, 0, 0).

Source code in src/scenex/model/_transform.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def rotated(
    self, angle: float, axis: ArrayLike = (0, 0, 1), about: ArrayLike | None = None
) -> Transform:
    """Return new transform, rotated some angle about a given axis.

    The rotation is applied *after* the transformations already present
    in the matrix.

    Parameters
    ----------
    angle : float
        The angle of rotation, in degrees.
    axis : array-like
        The x, y and z coordinates of the axis vector to rotate around.
        By default, will rotate around the z-axis: `(0, 0, 1)`.
    about : array-like or None
        The x, y and z coordinates to rotate around. If None, will rotate around
        the origin (0, 0, 0).
    """
    if about is not None:
        about = as_vec4(about)[0, :3]
        return self.translated(-about).dot(rotate(angle, axis)).translated(about)
    return self.dot(rotate(angle, axis))

scaled #

scaled(scale_factor: ArrayLike, center: ArrayLike | None = None) -> Transform

Return new transform, scaled about a given origin.

The scaling is applied after the transformations already present in the matrix.

Parameters:

  • scale_factor #

    (array - like) –

    Scale factors along x, y and z axes.

  • center #

    (array - like or None, default: None ) –

    The x, y and z coordinates to scale around. If None, (0, 0, 0) will be used.

Source code in src/scenex/model/_transform.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def scaled(
    self, scale_factor: ArrayLike, center: ArrayLike | None = None
) -> Transform:
    """Return new transform, scaled about a given origin.

    The scaling is applied *after* the transformations already present
    in the matrix.

    Parameters
    ----------
    scale_factor : array-like
        Scale factors along x, y and z axes.
    center : array-like or None
        The x, y and z coordinates to scale around. If None,
        (0, 0, 0) will be used.
    """
    _scale = scale(as_vec4(scale_factor, default=(1, 1, 1, 1))[0, :3])
    if center is not None:
        center = as_vec4(center)[0, :3]
        _scale = np.dot(np.dot(translate(-center), _scale), translate(center))
    return self.dot(_scale)

translated #

translated(pos: ArrayLike) -> Transform

Return new transform, translated by pos.

The translation is applied after the transformations already present in the matrix.

Parameters:

  • pos #

    (ArrayLike) –

    Position (x, y, z) to translate by.

Source code in src/scenex/model/_transform.py
188
189
190
191
192
193
194
195
196
197
198
199
200
def translated(self, pos: ArrayLike) -> Transform:
    """Return new transform, translated by pos.

    The translation is applied *after* the transformations already present
    in the matrix.

    Parameters
    ----------
    pos : ArrayLike
        Position (x, y, z) to translate by.
    """
    pos = as_vec4(np.array(pos))
    return self.dot(translate(pos[0, :3]))

UniformColor #

UniformColor(**data: Any)

Bases: ColorModel


              flowchart TD
              scenex.UniformColor[UniformColor]
              scenex.model._color.ColorModel[ColorModel]

                              scenex.model._color.ColorModel --> scenex.UniformColor
                


              click scenex.UniformColor href "" "scenex.UniformColor"
              click scenex.model._color.ColorModel href "" "scenex.model._color.ColorModel"
            

Uniform coloring strategy for scene nodes.

This model applies a single color to the entire geometry (mesh, line, points, etc). The color field is a single Color instance (e.g. Color("red")).

Examples:

Uniform coloring: >>> from cmap import Color >>> from scenex import UniformColor >>> model = UniformColor(color=Color("red"))

Source code in src/scenex/model/_color.py
23
24
25
26
def __init__(self, **data: Any) -> None:
    if type(self) is ColorModel:
        raise TypeError("ColorModel cannot be instantiated directly")
    super().__init__(**data)

VertexColors #

VertexColors(**data: Any)

Bases: ColorModel


              flowchart TD
              scenex.VertexColors[VertexColors]
              scenex.model._color.ColorModel[ColorModel]

                              scenex.model._color.ColorModel --> scenex.VertexColors
                


              click scenex.VertexColors href "" "scenex.VertexColors"
              click scenex.model._color.ColorModel href "" "scenex.model._color.ColorModel"
            

Per-vertex coloring strategy for mesh, line, or points nodes.

This model applies a different color to each vertex. The color field is a sequence of Color instances, one for each vertex.

Examples:

Per-vertex coloring: >>> from cmap import Color >>> from scenex import VertexColors >>> model = VertexColors( ... color=[Color("yellow"), Color("purple"), Color("cyan")] ... )

Source code in src/scenex/model/_color.py
23
24
25
26
def __init__(self, **data: Any) -> None:
    if type(self) is ColorModel:
        raise TypeError("ColorModel cannot be instantiated directly")
    super().__init__(**data)

View #

Bases: EventedBase


              flowchart TD
              scenex.View[View]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._base.EventedBase --> scenex.View
                


              click scenex.View href "" "scenex.View"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A rectangular viewport that displays a scene through a camera.

A View represents a rectangular area on a canvas that renders a scene graph through a specific camera perspective. Each view associates exactly one scene with one camera, defining what is displayed and how it is viewed. Multiple views can exist on a single canvas, each potentially showing different scenes or the same scene from different camera angles.

Examples:

Create a view with a scene containing an image: >>> import numpy as np >>> my_array = np.random.rand(100, 100).astype(np.float32) >>> scene = Scene(children=[Image(data=my_array)]) >>> view = View(scene=scene, camera=Camera())

Create a view with interactive camera and letterbox resizing: >>> view = View( ... scene=scene, ... camera=Camera(controller=PanZoom(), interactive=True), ... on_resize=Letterbox(), ... )

Add a view to a canvas: >>> canvas = Canvas() >>> canvas.views.append(view)

Methods:

  • filter_event

    Filters the event.

  • model_post_init

    Post-initialization hook for the model.

  • render

    Render the view to an array.

  • set_event_filter

    Registers a callable to filter events.

  • to_ray

    Compute the world-space ray for a canvas position within this view.

Attributes:

canvas property writable #

canvas: Canvas | None

The canvas that the view is on.

content_rect property #

content_rect: tuple[int, int, int, int] | None

Pixel content rect (x, y, width, height) of this view, excluding insets.

None if the view is not on a canvas.

rect property #

rect: tuple[int, int, int, int] | None

Pixel rect (x, y, width, height) of this view on its canvas.

None if the view is not on a canvas.

filter_event #

filter_event(event: Event) -> bool

Filters the event.

This method allows the larger view to react to events that: 1. Require summarization of multiple smaller event responses. 2. Could not be picked up by a node (e.g. mouse leaving an image).

Note the name has parity with Node.filter_event, but there's not much filtering going on.

Parameters:

  • event #

    (Event) –

    An event occurring in the view.

Returns:

  • bool

    True iff the event should not be propagated to other handlers.

Source code in src/scenex/model/_view.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def filter_event(self, event: Event) -> bool:
    """
    Filters the event.

    This method allows the larger view to react to events that:
    1. Require summarization of multiple smaller event responses.
    2. Could not be picked up by a node (e.g. mouse leaving an image).

    Note the name has parity with Node.filter_event, but there's not much filtering
    going on.

    Parameters
    ----------
    event : Event
        An event occurring in the view.

    Returns
    -------
    bool
        True iff the event should not be propagated to other handlers.
    """
    if self._filter:
        handled = self._filter(event)
        if not isinstance(handled, bool):
            # Some widget frameworks (i.e. Qt) get upset when non-booleans are
            # returned. If the event-filter does not return a boolean, rather than
            # letting that propagate upwards, we log a warning and return False.
            logger.warning(
                f"Event filter {self._filter} did not return a boolean. "
                "Returning False."
            )
            # Return False. We assume that if the user wanted to block future
            # processing, they'd be less likely to forget a boolean return.
            # Further, allowing downstream processing is a clear sign to they author
            # that they forgot to block propagation.
            handled = False
        return handled
    return False

model_post_init #

model_post_init(__context: Any) -> None

Post-initialization hook for the model.

Source code in src/scenex/model/_view.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def model_post_init(self, __context: Any) -> None:
    """Post-initialization hook for the model."""
    super().model_post_init(__context)
    self.camera.parent = self.scene
    # It is vital that whenever the view size changes, we allow the ResizePolicy to
    # respond. That size can change when (a) the layout changes, or (b) the canvas
    # resizes. We listen to (a) here and (b) in the canvas setter.
    self.layout.events.x_start.connect(self._on_size_change)
    self.layout.events.x_end.connect(self._on_size_change)
    self.layout.events.y_start.connect(self._on_size_change)
    self.layout.events.y_end.connect(self._on_size_change)

render #

render() -> ndarray

Render the view to an array.

Source code in src/scenex/model/_view.py
203
204
205
206
207
def render(self) -> np.ndarray:
    """Render the view to an array."""
    if adaptors := self._get_adaptors():
        return cast("ViewAdaptor", adaptors[0])._snx_render()
    raise RuntimeError("No adaptor found for View.")

set_event_filter #

set_event_filter(callable: Callable[[Event], bool] | None) -> Callable[[Event], bool] | None

Registers a callable to filter events.

Parameters:

  • callable #

    (Callable[[Event], bool] | None) –

    A callable that takes an Event and returns True if the event was handled, False otherwise. Passing None is equivalent to removing any existing filter. By returning True, the callable indicates that the event has been handled and should not be propagated to subsequent handlers.

Returns:

  • Callable[[Event], bool] | None

    The previous event filter, or None if there was no filter.

  • Note the name has parity with Node.filter_event, but there's not much filtering
  • going on.
Source code in src/scenex/model/_view.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def set_event_filter(
    self, callable: Callable[[Event], bool] | None
) -> Callable[[Event], bool] | None:
    """
    Registers a callable to filter events.

    Parameters
    ----------
    callable : Callable[[Event], bool] | None
        A callable that takes an Event and returns True if the event was handled,
        False otherwise. Passing None is equivalent to removing any existing filter.
        By returning True, the callable indicates that the event has been handled
        and should not be propagated to subsequent handlers.

    Returns
    -------
    Callable[[Event], bool] | None
        The previous event filter, or None if there was no filter.

    Note the name has parity with Node.filter_event, but there's not much filtering
    going on.
    """
    old, self._filter = self._filter, callable
    return old

to_ray #

to_ray(canvas_pos: tuple[float, float]) -> Ray | None

Compute the world-space ray for a canvas position within this view.

Parameters:

  • canvas_pos #

    (tuple[float, float]) –

    The (x, y) position in canvas pixel coordinates.

Returns:

  • Ray | None

    The world-space Ray, or None if this view has no canvas.

Notes

If canvas_pos falls outside this view's rectangle, a Ray is still returned — it simply points outside the visible frustum. Callers that need to restrict to within-bounds positions should check view.content_rect before calling this method.

Source code in src/scenex/model/_view.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def to_ray(self, canvas_pos: tuple[float, float]) -> Ray | None:
    """Compute the world-space ray for a canvas position within this view.

    Parameters
    ----------
    canvas_pos : tuple[float, float]
        The (x, y) position in canvas pixel coordinates.

    Returns
    -------
    Ray | None
        The world-space Ray, or None if this view has no canvas.

    Notes
    -----
    If ``canvas_pos`` falls outside this view's rectangle, a Ray is still
    returned — it simply points outside the visible frustum. Callers that
    need to restrict to within-bounds positions should check
    ``view.content_rect`` before calling this method.
    """
    # We need this view to be on a canvas to make sense of the canvas position.
    if self._canvas is None:
        logger.warning(
            "to_ray() called on a View not attached to a Canvas. "
            "Canvas coordinates have no meaning without a canvas."
        )
        return None
    # Convert canvas position to view position
    x, y = self._canvas.content_rect_for(self)[:2]
    view_pos = (canvas_pos[0] - x, canvas_pos[1] - y)
    # Convert view position to NDC
    ndc = self._to_ndc(view_pos)
    if ndc is None:
        return None
    return self._ndc_to_ray(ndc)

Volume #

Volume(*, children: Iterable[Node | dict[str, Any]] = (), **data: Unpack[NodeKwargs])

Bases: Image


              flowchart TD
              scenex.Volume[Volume]
              scenex.model._nodes.image.Image[Image]
              scenex.model._nodes.node.Node[Node]
              scenex.model._base.EventedBase[EventedBase]

                              scenex.model._nodes.image.Image --> scenex.Volume
                                scenex.model._nodes.node.Node --> scenex.model._nodes.image.Image
                                scenex.model._base.EventedBase --> scenex.model._nodes.node.Node
                




              click scenex.Volume href "" "scenex.Volume"
              click scenex.model._nodes.image.Image href "" "scenex.model._nodes.image.Image"
              click scenex.model._nodes.node.Node href "" "scenex.model._nodes.node.Node"
              click scenex.model._base.EventedBase href "" "scenex.model._base.EventedBase"
            

A 3D volumetric dataset rendered with volume rendering techniques.

Volume extends Image to support 3D volumetric data. Unlike images which are 2D arrays, volumes are 3D arrays of intensity values that are rendered using volume rendering techniques like maximum intensity projection (MIP) or isosurface rendering.

The volume uses ZYX dimension ordering, meaning data.shape = (depth, height, width). Like Image, the volume supports colormapping, intensity normalization, and gamma correction. The rendering mode determines how the 3D data is projected onto the 2D viewing plane.

Examples:

Create a volume with MIP rendering: >>> import numpy as np >>> data = np.random.rand(50, 100, 100) # ZYX dimensions >>> volume = Volume(data=data, render_mode="mip")

Create a volume with custom colormap and intensity range: >>> volume = Volume( ... data=data, ... cmap=Colormap("viridis"), ... clims=(0, 1), ... render_mode="iso", ... )

Notes

Volume inherits all Image attributes including data, cmap, clims, gamma, and interpolation. The data should be a 3D array with shape (depth, height, width) following ZYX convention.

Methods:

  • add_child

    Add a child node to this node.

  • iter_parents

    Return list of parents starting from this node.

  • model_post_init

    Called after the model is initialized.

  • path_to_node

    Return two lists describing the path from this node to another.

  • remove_child

    Remove a child node from this node. Does not raise if child is missing.

  • transform_to_node

    Return Transform that maps from coordinate frame of self to other.

  • tree_repr

    Return an ASCII/Unicode tree representation of self and its descendants.

Attributes:

Source code in src/scenex/model/_nodes/node.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __init__(
    self,
    *,
    children: Iterable[Node | dict[str, Any]] = (),
    **data: Unpack[NodeKwargs],
) -> None:
    # prevent direct instantiation.
    # makes it easier to use NodeUnion without having to deal with self-reference.
    if type(self) is Node:
        raise TypeError("Node cannot be instantiated directly. Use a subclass.")

    super().__init__(**data)  # pyright: ignore[reportCallIssue]

    for ch in children:
        if not isinstance(ch, Node):
            ch = self._validate_json(ch)
        self.add_child(ch)  # type: ignore [arg-type]
    if "parent" in data:
        # ensure parent-child consistency if parent is provided via kwargs
        self._update_parent_children(self, old_parent=None)

children property #

children: tuple[Node, ...]

Return a tuple of the children of this node.

add_child #

add_child(child: AnyNode) -> None

Add a child node to this node.

Source code in src/scenex/model/_nodes/node.py
222
223
224
225
226
227
228
def add_child(self, child: AnyNode) -> None:
    """Add a child node to this node."""
    if child in self._children:
        return
    self._children.append(child)
    child.parent = cast("AnyNode", self)
    self.child_added.emit(child)

iter_parents #

iter_parents() -> Iterator[Node]

Return list of parents starting from this node.

The chain ends at the first node with no parents.

Source code in src/scenex/model/_nodes/node.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def iter_parents(self) -> Iterator[Node]:
    """Return list of parents starting from this node.

    The chain ends at the first node with no parents.
    """
    yield self

    x = self
    while True:
        try:
            parent = x.parent
        except Exception:
            break
        if parent is None:
            break
        yield parent
        x = parent

model_post_init #

model_post_init(__context: Any) -> None

Called after the model is initialized.

Source code in src/scenex/model/_base.py
57
58
59
60
61
62
def model_post_init(self, __context: Any) -> None:
    """Called after the model is initialized."""
    objects.register(self)
    logger.debug(
        "Created model %-12r id: %s", type(self).__name__, self._model_id.hex[:8]
    )

path_to_node #

path_to_node(other: Node) -> tuple[list[Node], list[Node]]

Return two lists describing the path from this node to another.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • p1 ( list ) –

    First path (see below).

  • p2 ( list ) –

    Second path (see below).

Notes

The first list starts with this node and ends with the common parent between the endpoint nodes. The second list contains the remainder of the path from the common parent to the specified ending node.

For example, consider the following scenegraph::

A --- B --- C --- D
                           --- E --- F

Calling D.node_path(F) will return::

([D, C, B], [E, F])
Source code in src/scenex/model/_nodes/node.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path_to_node(self, other: Node) -> tuple[list[Node], list[Node]]:
    """Return two lists describing the path from this node to another.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    p1 : list
        First path (see below).
    p2 : list
        Second path (see below).

    Notes
    -----
    The first list starts with this node and ends with the common parent
    between the endpoint nodes. The second list contains the remainder of
    the path from the common parent to the specified ending node.

    For example, consider the following scenegraph::

        A --- B --- C --- D
               \
                --- E --- F

    Calling `D.node_path(F)` will return::

        ([D, C, B], [E, F])

    """
    my_parents = list(self.iter_parents())
    their_parents = list(other.iter_parents())
    common_parent = next((p for p in my_parents if p in their_parents), None)
    if common_parent is None:
        slf = f"{self.__class__.__name__} {id(self)}"
        nd = f"{other.__class__.__name__} {id(other)}"
        raise RuntimeError(f"No common parent between nodes {slf} and {nd}.")

    up = my_parents[: my_parents.index(common_parent) + 1]
    down = their_parents[: their_parents.index(common_parent)][::-1]
    return (up, down)

remove_child #

remove_child(child: AnyNode) -> None

Remove a child node from this node. Does not raise if child is missing.

Source code in src/scenex/model/_nodes/node.py
230
231
232
233
234
235
def remove_child(self, child: AnyNode) -> None:
    """Remove a child node from this node. Does not raise if child is missing."""
    if child in self._children:
        self._children.remove(child)
        child.parent = None
        self.child_removed.emit(child)

transform_to_node #

transform_to_node(other: Node) -> Transform

Return Transform that maps from coordinate frame of self to other.

Note that there must be a single path in the scenegraph that connects the two entities; otherwise an exception will be raised.

Parameters:

  • other #

    (instance of Node) –

    The other node.

Returns:

  • transform ( instance of ChainTransform ) –

    The transform.

Source code in src/scenex/model/_nodes/node.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def transform_to_node(self, other: Node) -> Transform:
    """Return Transform that maps from coordinate frame of `self` to `other`.

    Note that there must be a _single_ path in the scenegraph that connects
    the two entities; otherwise an exception will be raised.

    Parameters
    ----------
    other : instance of Node
        The other node.

    Returns
    -------
    transform : instance of ChainTransform
        The transform.
    """
    a, b = self.path_to_node(other)
    tforms = [n.transform for n in a[:-1]] + [n.transform.inv() for n in b]
    return Transform.chain(*tforms[::-1])

tree_repr #

tree_repr() -> str

Return an ASCII/Unicode tree representation of self and its descendants.

Source code in src/scenex/model/_nodes/node.py
383
384
385
386
387
def tree_repr(self) -> str:
    """Return an ASCII/Unicode tree representation of self and its descendants."""
    from scenex.util import tree_repr

    return tree_repr(self, node_repr=object.__repr__)

native #

native(canvas: Canvas, create: bool = True) -> Any

Get the native widget for the given canvas.

Parameters:

  • canvas #

    (Canvas) –

    The canvas for which to get the native widget.

  • create #

    (bool, default: True ) –

    Whether to create adaptors if they do not already exist. Defaults to True.

Returns:

  • Any

    The native widget associated with the canvas.

Raises:

  • KeyError

    If no adaptor yet exists for canvas and create=False.

Notes

This function is a convenience that retrieves the native widget from the first adaptor associated with the canvas. If multiple adaptors are present, it returns the native widget from the first one found.

Source code in src/scenex/util.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def native(canvas: model.Canvas, create: bool = True) -> Any:
    """Get the native widget for the given canvas.

    Parameters
    ----------
    canvas : model.Canvas
        The canvas for which to get the native widget.
    create : bool, optional
        Whether to create adaptors if they do not already exist. Defaults to `True`.

    Returns
    -------
    Any
        The native widget associated with the canvas.

    Raises
    ------
    KeyError
        If no adaptor yet exists for `canvas` and `create=False`.

    Notes
    -----
    This function is a convenience that retrieves the native widget from the first
    adaptor associated with the canvas. If multiple adaptors are present, it returns the
    native widget from the first one found.
    """
    for adaptor in canvas._get_adaptors(create=create):
        return cast("CanvasAdaptor", adaptor)._snx_get_native()

run #

run() -> None

Start the GUI event loop to display interactive visualizations.

This function enters the native event loop of the graphics backend, allowing interactive visualizations to respond to user input (mouse, keyboard) and remain visible. The function blocks until the visualization window is closed.

Call this function after creating and showing your visualizations with show(). It is only needed for desktop applications; in Jupyter notebooks, visualizations are displayed automatically without calling run().

Examples:

Basic usage with a scene: >>> import numpy as np >>> import scenex as snx >>> scene = snx.Scene( ... children=[snx.Image(data=np.random.rand(100, 100).astype(np.float32))] ... ) >>> snx.show(scene) Canvas(...) >>> snx.run() # Blocks until window is closed

Create multiple views and run: >>> canvas = snx.Canvas(views=[snx.View(), snx.View()]) >>> canvas.visible = True >>> snx.run()

Notes
  • This function blocks execution until all visualization windows are closed
  • Not needed in Jupyter notebooks or other interactive environments
  • Must be called after show() has been used to create visualizations
  • The event loop handles user interactions like pan, zoom, and picking
Source code in src/scenex/util.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def run() -> None:
    """Start the GUI event loop to display interactive visualizations.

    This function enters the native event loop of the graphics backend, allowing
    interactive visualizations to respond to user input (mouse, keyboard) and remain
    visible. The function blocks until the visualization window is closed.

    Call this function after creating and showing your visualizations with `show()`.
    It is only needed for desktop applications; in Jupyter notebooks, visualizations
    are displayed automatically without calling `run()`.

    Examples
    --------
    Basic usage with a scene:
        >>> import numpy as np
        >>> import scenex as snx
        >>> scene = snx.Scene(
        ...     children=[snx.Image(data=np.random.rand(100, 100).astype(np.float32))]
        ... )
        >>> snx.show(scene)
        Canvas(...)
        >>> snx.run()  # Blocks until window is closed

    Create multiple views and run:
        >>> canvas = snx.Canvas(views=[snx.View(), snx.View()])
        >>> canvas.visible = True
        >>> snx.run()

    Notes
    -----
    - This function blocks execution until all visualization windows are closed
    - Not needed in Jupyter notebooks or other interactive environments
    - Must be called after `show()` has been used to create visualizations
    - The event loop handles user interactions like pan, zoom, and picking
    """
    app().run()

set_cursor #

set_cursor(canvas: Canvas, cursor: CursorType) -> None

Set the cursor for the given canvas.

Parameters:

  • canvas #

    (Canvas) –

    The canvas on which to set the cursor.

  • cursor #

    (CursorType) –

    The type of cursor to set.

Notes

Practically and generally speaking, setting the cursor is an app-level concern. Unfortunately, setting the cursor often requires access to a native widget, meaning any scenex abstractions for setting the cursor will need as input the canvas model or a derivative adaptor. Proper separation of concerns suggests that the app-level API should just take the native widget. This function is a convenience that performs the intermediate steps to get the native widget from a canvas model.

Source code in src/scenex/util.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def set_cursor(canvas: model.Canvas, cursor: CursorType) -> None:
    """Set the cursor for the given canvas.

    Parameters
    ----------
    canvas : model.Canvas
        The canvas on which to set the cursor.
    cursor : CursorType
        The type of cursor to set.

    Notes
    -----
    Practically and generally speaking, setting the cursor is an app-level concern.
    Unfortunately, setting the cursor often requires access to a native widget, meaning
    any scenex abstractions for setting the cursor will need as input the canvas model
    or a derivative adaptor. Proper separation of concerns suggests that the app-level
    API should just take the native widget. This function is a convenience that performs
    the intermediate steps to get the native widget from a canvas model.
    """
    for adaptor in canvas._get_adaptors(create=True):
        widget = cast("CanvasAdaptor", adaptor)._snx_get_native()
        app().set_cursor(widget, cursor)

show #

show(obj: Node | View | Canvas, *, backend: str | None = None) -> Canvas

Display a visualization by creating a canvas and making it visible.

This is the primary function for creating and displaying scenex visualizations. It accepts nodes, views, or canvases, automatically wrapping them in the necessary container objects and creating the appropriate backend adaptors.

The function automatically fits the camera view to show all visible content and makes the canvas window visible. After calling show(), use run() to enter the event loop (in desktop applications) or continue working (in notebooks).

Parameters:

  • obj #

    (Node | View | Canvas) –

    The object to visualize: - Node (Image, Points, Line, etc.): Wrapped in Scene and View automatically - Scene: Wrapped in a View with a default Camera - View: Placed on a new Canvas - Canvas: Displayed directly (already contains Views)

  • backend #

    (str | None, default: None ) –

    Graphics backend to use ("pygfx" or "vispy"). If None, uses the backend specified by use(), SCENEX_CANVAS_BACKEND environment variable, or auto-detection. Default is None.

Returns:

  • Canvas

    The canvas containing the visualization. Can be used to further manipulate the display or access the created views.

Examples:

Show a simple image: >>> import numpy as np >>> import scenex as snx >>> data = np.random.rand(100, 100).astype(np.float32) >>> img = snx.Image(data=data) >>> canvas = snx.show(img) >>> snx.run()

Edit the returned canvas: >>> from cmap import Color >>> canvas.background_color = Color("white") >>> canvas.width = 800 >>> snx.run()

Notes
  • The camera is automatically zoomed to fit all visible content with 90% coverage
  • Canvas size defaults to the view's layout dimensions
  • Call run() after show() to enter the event loop in desktop applications
  • In Jupyter notebooks, visualizations appear automatically without run()
Source code in src/scenex/util.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def show(
    obj: model.Node | model.View | model.Canvas, *, backend: str | None = None
) -> model.Canvas:
    """Display a visualization by creating a canvas and making it visible.

    This is the primary function for creating and displaying scenex visualizations.
    It accepts nodes, views, or canvases, automatically wrapping them in the necessary
    container objects and creating the appropriate backend adaptors.

    The function automatically fits the camera view to show all visible content and
    makes the canvas window visible. After calling `show()`, use `run()` to enter
    the event loop (in desktop applications) or continue working (in notebooks).

    Parameters
    ----------
    obj : Node | View | Canvas
        The object to visualize:
        - Node (Image, Points, Line, etc.): Wrapped in Scene and View automatically
        - Scene: Wrapped in a View with a default Camera
        - View: Placed on a new Canvas
        - Canvas: Displayed directly (already contains Views)
    backend : str | None, optional
        Graphics backend to use ("pygfx" or "vispy"). If None, uses the backend
        specified by `use()`, `SCENEX_CANVAS_BACKEND` environment variable, or
        auto-detection. Default is `None`.

    Returns
    -------
    Canvas
        The canvas containing the visualization. Can be used to further manipulate
        the display or access the created views.

    Examples
    --------
    Show a simple image:
        >>> import numpy as np
        >>> import scenex as snx
        >>> data = np.random.rand(100, 100).astype(np.float32)
        >>> img = snx.Image(data=data)
        >>> canvas = snx.show(img)
        >>> snx.run()

    Edit the returned canvas:
        >>> from cmap import Color
        >>> canvas.background_color = Color("white")
        >>> canvas.width = 800
        >>> snx.run()

    Notes
    -----
    - The camera is automatically zoomed to fit all visible content with 90% coverage
    - Canvas size defaults to the view's layout dimensions
    - Call `run()` after `show()` to enter the event loop in desktop applications
    - In Jupyter notebooks, visualizations appear automatically without `run()`
    """
    from .adaptors import get_adaptor_registry

    view = None
    if isinstance(obj, model.Canvas):
        canvas = obj
    else:
        if isinstance(obj, model.View):
            view = obj
        elif isinstance(obj, model.Scene):
            view = model.View(scene=obj)
        elif isinstance(obj, model.Node):
            scene = model.Scene(children=[obj])
            view = model.View(scene=scene)

        canvas = model.Canvas()
        if view:
            canvas.views.append(view)

    canvas.visible = True
    reg = get_adaptor_registry(backend=backend)
    reg.get_adaptor(canvas, create=True)
    app().create_app()
    for view in canvas.views:
        projections.zoom_to_fit(view, zoom_factor=0.9, letterbox=True)

        # logger.debug("SHOW MODEL  %s", tree_repr(view.scene))
        # native_scene = view.scene._get_native()
        # logger.debug("SHOW NATIVE %s", tree_repr(native_scene))
    return canvas

use #

use(backend: KnownBackend | None = None) -> None

Set the graphics backend for rendering scenex visualizations.

This function allows you to explicitly select which graphics library (backend) scenex should use for rendering. It is the goal of scenex to support the full range of model API for each backend.

If not called, scenex will automatically select an arbitrary available backend. You can also set the backend via the SCENEX_CANVAS_BACKEND environment variable.

Parameters:

  • backend #

    (Literal['pygfx', 'vispy'] | None, default: None ) –

    The graphics backend to use: - "pygfx": Modern WebGPU-based renderer with advanced features - "vispy": OpenGL-based renderer with broad compatibility - None: Reset to auto-detection

Raises:

  • ValueError

    If the specified backend is not one of the known backends.

Examples:

Use pygfx backend explicitly: >>> import scenex as snx >>> snx.use("pygfx") # doctest: +SKIP >>> canvas = snx.show(snx.View())

Use vispy backend: >>> snx.use("vispy") # doctest: +SKIP >>> canvas = snx.show(snx.Scene())

Reset to auto-detection: >>> snx.use(None)

Notes

The backend selection follows this priority order: 1. Backend specified by this function 2. SCENEX_CANVAS_BACKEND environment variable 3. Auto-detection (pygfx preferred, then vispy)

This function should be called before creating any visualizations.

Source code in src/scenex/adaptors/_auto.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def use(backend: KnownBackend | None = None) -> None:
    """Set the graphics backend for rendering scenex visualizations.

    This function allows you to explicitly select which graphics library (backend)
    scenex should use for rendering. It is the goal of scenex to support the full range
    of model API for each backend.

    If not called, scenex will automatically select an arbitrary available backend. You
    can also set the backend via the SCENEX_CANVAS_BACKEND environment variable.

    Parameters
    ----------
    backend : Literal["pygfx", "vispy"] | None
        The graphics backend to use:
        - "pygfx": Modern WebGPU-based renderer with advanced features
        - "vispy": OpenGL-based renderer with broad compatibility
        - None: Reset to auto-detection

    Raises
    ------
    ValueError
        If the specified backend is not one of the known backends.

    Examples
    --------
    Use pygfx backend explicitly:
        >>> import scenex as snx
        >>> snx.use("pygfx")  # doctest: +SKIP
        >>> canvas = snx.show(snx.View())

    Use vispy backend:
        >>> snx.use("vispy")  # doctest: +SKIP
        >>> canvas = snx.show(snx.Scene())

    Reset to auto-detection:
        >>> snx.use(None)

    Notes
    -----
    The backend selection follows this priority order:
    1. Backend specified by this function
    2. SCENEX_CANVAS_BACKEND environment variable
    3. Auto-detection (pygfx preferred, then vispy)

    This function should be called before creating any visualizations.
    """
    global _USE
    if backend is None or _ensure_valid_backend(backend):
        _USE = backend