Skip to content

GSP Extra API Reference

The GSP Extra package provides additional utilities, helpers, and high-level components that extend the core GSP functionality, including animation, 3D object management, camera controls, and viewport event handling.

Overview

gsp_extra

Bufferx Module

Extended buffer utilities with numpy integration.

gsp_extra.bufferx

Bufferx extra module re-exporting Bufferx from gsp_matplotlib.extra.bufferx.

Object3D Module

3D object management with transformations, visuals, and cameras.

gsp_extra.object3d

Object3D class for managing 3D objects, their transformations, visuals, and cameras.

Object3D

Class representing a 3D object with transformation, visuals, and cameras.

Source code in src/gsp_extra/object3d.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
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
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
class Object3D:
    """Class representing a 3D object with transformation, visuals, and cameras."""

    def __init__(self, name: str | None = None):
        """Initialize the Object3D.

        Args:
            name (str | None): Optional name for the Object3D. If None, a default name is generated.
        """
        self.uuid = UuidUtils.generate_uuid()
        """uuid of the visual being wrapped."""

        self.name: str = name if name is not None else f"a {self.__class__.__name__} - {self.uuid}"
        """name of this Object3D."""

        self.matrix_world: np.ndarray = np.eye(4, dtype=np.float32)
        """matrix world of this Object3D"""
        self.matrix_local: np.ndarray = np.eye(4, dtype=np.float32)
        """matrix local of this Object3D"""

        self.dont_update_matrix_world: bool = False
        """if True, the world matrix won't be updated."""
        self.dont_update_matrix_local: bool = False
        """if True, the local matrix won't be updated."""

        self.rotation_order: str = "XYZ"
        """rotation order for euler angles."""
        self.position: np.ndarray = np.zeros(3, dtype=np.float32)
        """position of this Object3D."""
        self.euler: np.ndarray = np.zeros(3, dtype=np.float32)
        """euler angles in radians of this Object3D."""
        self.scale: np.ndarray = np.ones(3, dtype=np.float32)
        """scale of this Object3D."""

        self.children: list[Object3D] = []
        """list of children Object3D."""

        self.visuals: list[VisualBase] = []
        """list of visuals attached to this Object3D."""
        self.cameras: list[Camera] = []
        """list of cameras attached to this Object3D."""

    def __repr__(self) -> str:
        """String representation of the Object3D."""
        return f"Object3D(name={self.name})"

    # =============================================================================
    # .add/.remove
    # =============================================================================

    def add(self, child: "Object3D"):
        """Add a child Object3D to this Object3D.

        Args:
            child (Object3D): child to add.
        """
        self.children.append(child)

    def remove(self, child: "Object3D"):
        """Remove a child Object3D from this Object3D.

        Args:
            child (Object3D): child to remove.
        """
        self.children.remove(child)

    # =============================================================================
    # .attach_visual/.detach_visual
    # =============================================================================

    def attach_visual(self, visual: VisualBase):
        """Add a visual to this Object3D.

        Args:
            visual (VisualBase): visual to add.
        """
        self.visuals.append(visual)

    def detach_visual(self, visual: VisualBase):
        """Remove a visual from this Object3D.

        Args:
            visual (VisualBase): visual to remove.
        """
        self.visuals.remove(visual)

    # =============================================================================
    # .attach_camera/.detach_camera
    # =============================================================================

    def attach_camera(self, camera: Camera):
        """Add a camera to this Object3D.

        Args:
            camera (Camera): camera to add.
        """
        self.cameras.append(camera)

    def detach_camera(self, camera: Camera):
        """Remove a camera from this Object3D.

        Args:
            camera (Camera): camera to remove.
        """
        self.cameras.remove(camera)

    # =============================================================================
    # .traverse
    # =============================================================================

    def traverse(self):
        """Generator to traverse the Object3D hierarchy."""
        yield self
        for child in self.children:
            yield from child.traverse()

    # =============================================================================
    # .update_matrix_*
    # =============================================================================

    def update_matrix_local(self, force_update: bool = False) -> None:
        """Upload the local matrix from position, euler and scale.

        Args:
            force_update (bool): if True, forces the update even if dont_update_matrix_local is True. Defaults to False.
        """
        # honor dont_update_matrix_local flag
        if self.dont_update_matrix_local and not force_update:
            return

        # compute the scale matrix
        scale_matrix = glm.scale(self.scale)

        # compute the rotation matrix in the specified order
        rotation_matrix = np.eye(4, dtype=np.float32)
        for axis in self.rotation_order:
            if axis == "X":
                rotation_matrix = rotation_matrix @ glm.xrotate(self.euler[0] / np.pi * 180.0)
            elif axis == "Y":
                rotation_matrix = rotation_matrix @ glm.yrotate(self.euler[1] / np.pi * 180.0)
            elif axis == "Z":
                rotation_matrix = rotation_matrix @ glm.zrotate(self.euler[2] / np.pi * 180.0)

        # compute the translation matrix
        translation_matrix = glm.translate(self.position)

        # set the local matrix
        self.matrix_local = translation_matrix @ rotation_matrix @ scale_matrix

    def update_matrix_world(self, parent_matrix_world: np.ndarray | None = None, force_update: bool = False) -> None:
        """Compute the world matrix from the local matrix and the parent's world matrix.

        Args:
            parent_matrix_world (np.ndarray | None): parent's world matrix. Defaults to None.
            force_update (bool): if True, forces the update even if dont_update_matrix_world is True. Defaults to False.
        """
        # update local matrix
        self.update_matrix_local(force_update=force_update)

        # update world matrix - honor dont_update_matrix_world flag
        if parent_matrix_world is None:
            self.matrix_world = self.matrix_local
        elif not self.dont_update_matrix_world or force_update:
            self.matrix_world = parent_matrix_world @ self.matrix_local

        # update children
        for child in self.children:
            child.update_matrix_world(self.matrix_world)

    # =============================================================================
    #
    # =============================================================================

    @staticmethod
    def update_camera_view_matrices(scene: "Object3D") -> None:
        """Update the view matrices of all cameras attached to the Object3D hierarchy.

        Args:
            scene (Object3D): root Object3D of the scene.
        """
        for object3d in scene.traverse():
            for camera in object3d.cameras:
                view_matrix_numpy = np.linalg.inv(object3d.matrix_world)
                view_matrix_buffer = Bufferx.from_numpy(np.array([view_matrix_numpy]), BufferType.mat4)
                camera.set_view_matrix(view_matrix_buffer)

    @staticmethod
    def to_render_args(viewport: Viewport, scene: "Object3D", camera: Camera) -> tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]:
        """Render the scene using the provided renderer.

        Args:
            viewport (Viewport): viewport to render to.
            scene (Object3D): root Object3D of the scene.
            camera (Camera): camera to use for rendering.

        Returns:
            tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]: viewports, visuals, model matrices and cameras.
        """
        # gather all visuals, model matrices and cameras
        visuals = [visual for object3d in scene.traverse() for visual in object3d.visuals]
        model_matrices_numpy = [object3d.matrix_world for object3d in scene.traverse() for _ in object3d.visuals]
        model_matrices_buffer = [Bufferx.from_numpy(np.array([model_matrix_numpy]), BufferType.mat4) for model_matrix_numpy in model_matrices_numpy]
        model_matrices = [typing.cast(TransBuf, model_matrix_buffer) for model_matrix_buffer in model_matrices_buffer]
        viewports = [viewport for _ in range(len(visuals))]
        cameras = [camera for _ in range(len(visuals))]

        return viewports, visuals, model_matrices, cameras

    @staticmethod
    def pre_render(viewport: Viewport, scene: "Object3D", camera: Camera) -> tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]:
        """Prepare the scene for rendering by updating matrices and gathering render arguments.

        Args:
            viewport (Viewport): viewport to render to.
            scene (Object3D): root Object3D of the scene.
            camera (Camera): camera to use for rendering.

        Returns:
            tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]: viewports, visuals, model matrices and cameras.
        """
        # update all world matrices
        scene.update_matrix_world()

        # update camera view matrices
        Object3D.update_camera_view_matrices(scene)

        # gather all visuals, model matrices and cameras
        viewports, visuals, model_matrices_buffer, cameras = Object3D.to_render_args(viewport, scene, camera)

        return viewports, visuals, model_matrices_buffer, cameras

cameras: list[Camera] = [] instance-attribute

list of cameras attached to this Object3D.

children: list[Object3D] = [] instance-attribute

list of children Object3D.

dont_update_matrix_local: bool = False instance-attribute

if True, the local matrix won't be updated.

dont_update_matrix_world: bool = False instance-attribute

if True, the world matrix won't be updated.

euler: np.ndarray = np.zeros(3, dtype=(np.float32)) instance-attribute

euler angles in radians of this Object3D.

matrix_local: np.ndarray = np.eye(4, dtype=(np.float32)) instance-attribute

matrix local of this Object3D

matrix_world: np.ndarray = np.eye(4, dtype=(np.float32)) instance-attribute

matrix world of this Object3D

name: str = name if name is not None else f'a {self.__class__.__name__} - {self.uuid}' instance-attribute

name of this Object3D.

position: np.ndarray = np.zeros(3, dtype=(np.float32)) instance-attribute

position of this Object3D.

rotation_order: str = 'XYZ' instance-attribute

rotation order for euler angles.

scale: np.ndarray = np.ones(3, dtype=(np.float32)) instance-attribute

scale of this Object3D.

uuid = UuidUtils.generate_uuid() instance-attribute

uuid of the visual being wrapped.

visuals: list[VisualBase] = [] instance-attribute

list of visuals attached to this Object3D.

__init__(name: str | None = None)

Initialize the Object3D.

Parameters:

Name Type Description Default
name str | None

Optional name for the Object3D. If None, a default name is generated.

None
Source code in src/gsp_extra/object3d.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def __init__(self, name: str | None = None):
    """Initialize the Object3D.

    Args:
        name (str | None): Optional name for the Object3D. If None, a default name is generated.
    """
    self.uuid = UuidUtils.generate_uuid()
    """uuid of the visual being wrapped."""

    self.name: str = name if name is not None else f"a {self.__class__.__name__} - {self.uuid}"
    """name of this Object3D."""

    self.matrix_world: np.ndarray = np.eye(4, dtype=np.float32)
    """matrix world of this Object3D"""
    self.matrix_local: np.ndarray = np.eye(4, dtype=np.float32)
    """matrix local of this Object3D"""

    self.dont_update_matrix_world: bool = False
    """if True, the world matrix won't be updated."""
    self.dont_update_matrix_local: bool = False
    """if True, the local matrix won't be updated."""

    self.rotation_order: str = "XYZ"
    """rotation order for euler angles."""
    self.position: np.ndarray = np.zeros(3, dtype=np.float32)
    """position of this Object3D."""
    self.euler: np.ndarray = np.zeros(3, dtype=np.float32)
    """euler angles in radians of this Object3D."""
    self.scale: np.ndarray = np.ones(3, dtype=np.float32)
    """scale of this Object3D."""

    self.children: list[Object3D] = []
    """list of children Object3D."""

    self.visuals: list[VisualBase] = []
    """list of visuals attached to this Object3D."""
    self.cameras: list[Camera] = []
    """list of cameras attached to this Object3D."""

__repr__() -> str

String representation of the Object3D.

Source code in src/gsp_extra/object3d.py
62
63
64
def __repr__(self) -> str:
    """String representation of the Object3D."""
    return f"Object3D(name={self.name})"

add(child: Object3D)

Add a child Object3D to this Object3D.

Parameters:

Name Type Description Default
child gsp_extra.object3d.Object3D

child to add.

required
Source code in src/gsp_extra/object3d.py
70
71
72
73
74
75
76
def add(self, child: "Object3D"):
    """Add a child Object3D to this Object3D.

    Args:
        child (Object3D): child to add.
    """
    self.children.append(child)

attach_camera(camera: Camera)

Add a camera to this Object3D.

Parameters:

Name Type Description Default
camera gsp.core.camera.Camera

camera to add.

required
Source code in src/gsp_extra/object3d.py
110
111
112
113
114
115
116
def attach_camera(self, camera: Camera):
    """Add a camera to this Object3D.

    Args:
        camera (Camera): camera to add.
    """
    self.cameras.append(camera)

attach_visual(visual: VisualBase)

Add a visual to this Object3D.

Parameters:

Name Type Description Default
visual gsp.types.visual_base.VisualBase

visual to add.

required
Source code in src/gsp_extra/object3d.py
90
91
92
93
94
95
96
def attach_visual(self, visual: VisualBase):
    """Add a visual to this Object3D.

    Args:
        visual (VisualBase): visual to add.
    """
    self.visuals.append(visual)

detach_camera(camera: Camera)

Remove a camera from this Object3D.

Parameters:

Name Type Description Default
camera gsp.core.camera.Camera

camera to remove.

required
Source code in src/gsp_extra/object3d.py
118
119
120
121
122
123
124
def detach_camera(self, camera: Camera):
    """Remove a camera from this Object3D.

    Args:
        camera (Camera): camera to remove.
    """
    self.cameras.remove(camera)

detach_visual(visual: VisualBase)

Remove a visual from this Object3D.

Parameters:

Name Type Description Default
visual gsp.types.visual_base.VisualBase

visual to remove.

required
Source code in src/gsp_extra/object3d.py
 98
 99
100
101
102
103
104
def detach_visual(self, visual: VisualBase):
    """Remove a visual from this Object3D.

    Args:
        visual (VisualBase): visual to remove.
    """
    self.visuals.remove(visual)

pre_render(viewport: Viewport, scene: Object3D, camera: Camera) -> tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]] staticmethod

Prepare the scene for rendering by updating matrices and gathering render arguments.

Parameters:

Name Type Description Default
viewport gsp.core.Viewport

viewport to render to.

required
scene gsp_extra.object3d.Object3D

root Object3D of the scene.

required
camera gsp.core.camera.Camera

camera to use for rendering.

required

Returns:

Type Description
tuple[list[gsp.core.Viewport], list[gsp.types.visual_base.VisualBase], list[gsp.types.transbuf.TransBuf], list[gsp.core.camera.Camera]]

tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]: viewports, visuals, model matrices and cameras.

Source code in src/gsp_extra/object3d.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
@staticmethod
def pre_render(viewport: Viewport, scene: "Object3D", camera: Camera) -> tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]:
    """Prepare the scene for rendering by updating matrices and gathering render arguments.

    Args:
        viewport (Viewport): viewport to render to.
        scene (Object3D): root Object3D of the scene.
        camera (Camera): camera to use for rendering.

    Returns:
        tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]: viewports, visuals, model matrices and cameras.
    """
    # update all world matrices
    scene.update_matrix_world()

    # update camera view matrices
    Object3D.update_camera_view_matrices(scene)

    # gather all visuals, model matrices and cameras
    viewports, visuals, model_matrices_buffer, cameras = Object3D.to_render_args(viewport, scene, camera)

    return viewports, visuals, model_matrices_buffer, cameras

remove(child: Object3D)

Remove a child Object3D from this Object3D.

Parameters:

Name Type Description Default
child gsp_extra.object3d.Object3D

child to remove.

required
Source code in src/gsp_extra/object3d.py
78
79
80
81
82
83
84
def remove(self, child: "Object3D"):
    """Remove a child Object3D from this Object3D.

    Args:
        child (Object3D): child to remove.
    """
    self.children.remove(child)

to_render_args(viewport: Viewport, scene: Object3D, camera: Camera) -> tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]] staticmethod

Render the scene using the provided renderer.

Parameters:

Name Type Description Default
viewport gsp.core.Viewport

viewport to render to.

required
scene gsp_extra.object3d.Object3D

root Object3D of the scene.

required
camera gsp.core.camera.Camera

camera to use for rendering.

required

Returns:

Type Description
tuple[list[gsp.core.Viewport], list[gsp.types.visual_base.VisualBase], list[gsp.types.transbuf.TransBuf], list[gsp.core.camera.Camera]]

tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]: viewports, visuals, model matrices and cameras.

Source code in src/gsp_extra/object3d.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
@staticmethod
def to_render_args(viewport: Viewport, scene: "Object3D", camera: Camera) -> tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]:
    """Render the scene using the provided renderer.

    Args:
        viewport (Viewport): viewport to render to.
        scene (Object3D): root Object3D of the scene.
        camera (Camera): camera to use for rendering.

    Returns:
        tuple[list[Viewport], list[VisualBase], list[TransBuf], list[Camera]]: viewports, visuals, model matrices and cameras.
    """
    # gather all visuals, model matrices and cameras
    visuals = [visual for object3d in scene.traverse() for visual in object3d.visuals]
    model_matrices_numpy = [object3d.matrix_world for object3d in scene.traverse() for _ in object3d.visuals]
    model_matrices_buffer = [Bufferx.from_numpy(np.array([model_matrix_numpy]), BufferType.mat4) for model_matrix_numpy in model_matrices_numpy]
    model_matrices = [typing.cast(TransBuf, model_matrix_buffer) for model_matrix_buffer in model_matrices_buffer]
    viewports = [viewport for _ in range(len(visuals))]
    cameras = [camera for _ in range(len(visuals))]

    return viewports, visuals, model_matrices, cameras

traverse()

Generator to traverse the Object3D hierarchy.

Source code in src/gsp_extra/object3d.py
130
131
132
133
134
def traverse(self):
    """Generator to traverse the Object3D hierarchy."""
    yield self
    for child in self.children:
        yield from child.traverse()

update_camera_view_matrices(scene: Object3D) -> None staticmethod

Update the view matrices of all cameras attached to the Object3D hierarchy.

Parameters:

Name Type Description Default
scene gsp_extra.object3d.Object3D

root Object3D of the scene.

required
Source code in src/gsp_extra/object3d.py
193
194
195
196
197
198
199
200
201
202
203
204
@staticmethod
def update_camera_view_matrices(scene: "Object3D") -> None:
    """Update the view matrices of all cameras attached to the Object3D hierarchy.

    Args:
        scene (Object3D): root Object3D of the scene.
    """
    for object3d in scene.traverse():
        for camera in object3d.cameras:
            view_matrix_numpy = np.linalg.inv(object3d.matrix_world)
            view_matrix_buffer = Bufferx.from_numpy(np.array([view_matrix_numpy]), BufferType.mat4)
            camera.set_view_matrix(view_matrix_buffer)

update_matrix_local(force_update: bool = False) -> None

Upload the local matrix from position, euler and scale.

Parameters:

Name Type Description Default
force_update bool

if True, forces the update even if dont_update_matrix_local is True. Defaults to False.

False
Source code in src/gsp_extra/object3d.py
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
def update_matrix_local(self, force_update: bool = False) -> None:
    """Upload the local matrix from position, euler and scale.

    Args:
        force_update (bool): if True, forces the update even if dont_update_matrix_local is True. Defaults to False.
    """
    # honor dont_update_matrix_local flag
    if self.dont_update_matrix_local and not force_update:
        return

    # compute the scale matrix
    scale_matrix = glm.scale(self.scale)

    # compute the rotation matrix in the specified order
    rotation_matrix = np.eye(4, dtype=np.float32)
    for axis in self.rotation_order:
        if axis == "X":
            rotation_matrix = rotation_matrix @ glm.xrotate(self.euler[0] / np.pi * 180.0)
        elif axis == "Y":
            rotation_matrix = rotation_matrix @ glm.yrotate(self.euler[1] / np.pi * 180.0)
        elif axis == "Z":
            rotation_matrix = rotation_matrix @ glm.zrotate(self.euler[2] / np.pi * 180.0)

    # compute the translation matrix
    translation_matrix = glm.translate(self.position)

    # set the local matrix
    self.matrix_local = translation_matrix @ rotation_matrix @ scale_matrix

update_matrix_world(parent_matrix_world: np.ndarray | None = None, force_update: bool = False) -> None

Compute the world matrix from the local matrix and the parent's world matrix.

Parameters:

Name Type Description Default
parent_matrix_world numpy.ndarray | None

parent's world matrix. Defaults to None.

None
force_update bool

if True, forces the update even if dont_update_matrix_world is True. Defaults to False.

False
Source code in src/gsp_extra/object3d.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def update_matrix_world(self, parent_matrix_world: np.ndarray | None = None, force_update: bool = False) -> None:
    """Compute the world matrix from the local matrix and the parent's world matrix.

    Args:
        parent_matrix_world (np.ndarray | None): parent's world matrix. Defaults to None.
        force_update (bool): if True, forces the update even if dont_update_matrix_world is True. Defaults to False.
    """
    # update local matrix
    self.update_matrix_local(force_update=force_update)

    # update world matrix - honor dont_update_matrix_world flag
    if parent_matrix_world is None:
        self.matrix_world = self.matrix_local
    elif not self.dont_update_matrix_world or force_update:
        self.matrix_world = parent_matrix_world @ self.matrix_local

    # update children
    for child in self.children:
        child.update_matrix_world(self.matrix_world)

Animator Module

Animation utilities for different rendering backends.

gsp_extra.animator

"Animator module for GSP visualizations.

Animator Base

gsp_extra.animator.animator_base

Abstract base class for GSP scene animators.

AnimatorBase

Bases: abc.ABC

Abstract base class for GSP scene animators.

Defines the interface for animator implementations that handle frame-by-frame updates of GSP visualizations. Concrete implementations should provide renderer-specific animation loop mechanisms.

Source code in src/gsp_extra/animator/animator_base.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class AnimatorBase(ABC):
    """Abstract base class for GSP scene animators.

    Defines the interface for animator implementations that handle frame-by-frame
    updates of GSP visualizations. Concrete implementations should provide
    renderer-specific animation loop mechanisms.
    """

    __slots__ = ()

    on_video_saved: Event[VideoSavedCalledback]
    """Event triggered when the video is saved."""

    @abstractmethod
    def add_callback(self, func: AnimatorFunc) -> None:
        """Add a callback to the animation loop.

        Args:
            func: The callback function to add. It should accept a delta_time float
                  and return a sequence of VisualBase objects that were modified.
        """
        pass

    @abstractmethod
    def remove_callback(self, func: AnimatorFunc) -> None:
        """Remove a callback from the animation loop.

        Args:
            func: The callback function to remove. Must be the same function
                  object that was previously added.
        """
        pass

    @abstractmethod
    def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
        """Decorator to register a callback function to the animation loop.

        This method should be implemented to allow decorator-style registration
        of animation callbacks.

        Args:
            func: The callback function to decorate and add to the animation loop.
                  Should accept delta_time (float) and return a sequence of modified VisualBase objects.

        Returns:
            The wrapper function that will be called on each animation frame.
        """
        pass

    @abstractmethod
    def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
        """Start the animation loop.

        Begins animating the scene using the provided viewports, visuals, model matrices,
        and cameras. The registered callbacks will be invoked on each frame to update
        the scene.

        Args:
            viewports: Sequence of viewports to render.
            visuals: Sequence of visual objects to animate.
            model_matrices: Sequence of transformation buffers for the visuals.
            cameras: Sequence of cameras for each viewport.
        """
        pass

    @abstractmethod
    def stop(self) -> None:
        """Stop the animation loop.

        Cleans up animation state and stops any ongoing animation.
        """
        pass

on_video_saved: Event[VideoSavedCalledback] instance-attribute

Event triggered when the video is saved.

add_callback(func: AnimatorFunc) -> None abstractmethod

Add a callback to the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The callback function to add. It should accept a delta_time float and return a sequence of VisualBase objects that were modified.

required
Source code in src/gsp_extra/animator/animator_base.py
29
30
31
32
33
34
35
36
37
@abstractmethod
def add_callback(self, func: AnimatorFunc) -> None:
    """Add a callback to the animation loop.

    Args:
        func: The callback function to add. It should accept a delta_time float
              and return a sequence of VisualBase objects that were modified.
    """
    pass

event_listener(func: AnimatorFunc) -> AnimatorFunc abstractmethod

Decorator to register a callback function to the animation loop.

This method should be implemented to allow decorator-style registration of animation callbacks.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The callback function to decorate and add to the animation loop. Should accept delta_time (float) and return a sequence of modified VisualBase objects.

required

Returns:

Type Description
gsp_extra.animator.animator_types.AnimatorFunc

The wrapper function that will be called on each animation frame.

Source code in src/gsp_extra/animator/animator_base.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@abstractmethod
def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
    """Decorator to register a callback function to the animation loop.

    This method should be implemented to allow decorator-style registration
    of animation callbacks.

    Args:
        func: The callback function to decorate and add to the animation loop.
              Should accept delta_time (float) and return a sequence of modified VisualBase objects.

    Returns:
        The wrapper function that will be called on each animation frame.
    """
    pass

remove_callback(func: AnimatorFunc) -> None abstractmethod

Remove a callback from the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The callback function to remove. Must be the same function object that was previously added.

required
Source code in src/gsp_extra/animator/animator_base.py
39
40
41
42
43
44
45
46
47
@abstractmethod
def remove_callback(self, func: AnimatorFunc) -> None:
    """Remove a callback from the animation loop.

    Args:
        func: The callback function to remove. Must be the same function
              object that was previously added.
    """
    pass

start(viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None abstractmethod

Start the animation loop.

Begins animating the scene using the provided viewports, visuals, model matrices, and cameras. The registered callbacks will be invoked on each frame to update the scene.

Parameters:

Name Type Description Default
viewports typing.Sequence[gsp.core.viewport.Viewport]

Sequence of viewports to render.

required
visuals typing.Sequence[gsp.types.visual_base.VisualBase]

Sequence of visual objects to animate.

required
model_matrices typing.Sequence[gsp.types.transbuf.TransBuf]

Sequence of transformation buffers for the visuals.

required
cameras typing.Sequence[gsp.core.camera.Camera]

Sequence of cameras for each viewport.

required
Source code in src/gsp_extra/animator/animator_base.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@abstractmethod
def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
    """Start the animation loop.

    Begins animating the scene using the provided viewports, visuals, model matrices,
    and cameras. The registered callbacks will be invoked on each frame to update
    the scene.

    Args:
        viewports: Sequence of viewports to render.
        visuals: Sequence of visual objects to animate.
        model_matrices: Sequence of transformation buffers for the visuals.
        cameras: Sequence of cameras for each viewport.
    """
    pass

stop() -> None abstractmethod

Stop the animation loop.

Cleans up animation state and stops any ongoing animation.

Source code in src/gsp_extra/animator/animator_base.py
81
82
83
84
85
86
87
@abstractmethod
def stop(self) -> None:
    """Stop the animation loop.

    Cleans up animation state and stops any ongoing animation.
    """
    pass

Animator Matplotlib

gsp_extra.animator.animator_matplotlib

Matplotlib-based animator for GSP scenes.

Provides animation capabilities using a MatplotlibRenderer backend with support for real-time animation display and video export.

AnimatorMatplotlib

Bases: gsp_extra.animator.animator_base.AnimatorBase

Animator for GSP scenes using a Matplotlib renderer.

Manages animation loops with callback functions that update visuals each frame. Supports real-time display and video export in various formats. Uses Matplotlib's FuncAnimation for efficient rendering updates.

Source code in src/gsp_extra/animator/animator_matplotlib.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
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
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
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
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
class AnimatorMatplotlib(AnimatorBase):
    """Animator for GSP scenes using a Matplotlib renderer.

    Manages animation loops with callback functions that update visuals each frame.
    Supports real-time display and video export in various formats. Uses Matplotlib's
    FuncAnimation for efficient rendering updates.
    """

    def __init__(
        self,
        matplotlib_renderer: MatplotlibRenderer,
        fps: int = 50,
        video_duration: float = 10.0,
        video_path: str | None = None,
        video_writer: str | None = None,
    ):
        """Initialize the Matplotlib animator.

        Args:
            matplotlib_renderer: The Matplotlib renderer to use for rendering frames.
            fps: Target frames per second for the animation.
            video_duration: Total duration of the animation in seconds.
            video_path: Path where the video should be saved. If None, no video is saved.
            video_writer: Video writer to use ("ffmpeg" or "pillow"). If None, auto-detected from extension.

        Raises:
            ValueError: If the video format is not supported.
        """
        self._callbacks: list[AnimatorFunc] = []
        self._matplotlib_renderer = matplotlib_renderer
        self._fps = fps
        self._video_duration = video_duration
        self._video_path = video_path
        self._video_writer: str | None = None
        self._time_last_update: float | None = None

        self._funcAnimation: matplotlib.animation.FuncAnimation | None = None

        self._canvas: Canvas | None = None
        self._viewports: Sequence[Viewport] | None = None
        self._visuals: Sequence[VisualBase] | None = None
        self._model_matrices: Sequence[TransBuf] | None = None
        self._cameras: Sequence[Camera] | None = None

        self.on_video_saved = Event[VideoSavedCalledback]()
        """Event triggered when the video has been successfully saved to disk."""

        # guess the video writer from the file extension if not provided
        if self._video_path is not None:
            if video_writer is not None:
                self._video_writer = video_writer
            else:
                video_ext = os.path.splitext(self._video_path)[1].lower()
                if video_ext in [".mp4", ".m4v", ".mov"]:
                    self._video_writer = "ffmpeg"
                elif video_ext in [".gif", ".apng", ".webp"]:
                    self._video_writer = "pillow"
                else:
                    raise ValueError(f"Unsupported video format: {video_ext}")

    # =============================================================================
    # .add_callback/.remove_callback/.decorator
    # =============================================================================

    def add_callback(self, func: AnimatorFunc) -> None:
        """Add a callback to the animation loop.

        Args:
            func: The animator function to call on each frame.
        """
        self._callbacks.append(func)

    def remove_callback(self, func: AnimatorFunc) -> None:
        """Remove a callback from the animation loop.

        Args:
            func: The animator function to remove.
        """
        self._callbacks.remove(func)

    def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
        """Decorator to add a callback to the animation loop.

        Args:
            func: The animator function to register as a callback.

        Returns:
            The wrapped animator function.

        Usage:
            ```python
                @animation_loop.event_listener
                def my_callback(delta_time: float) -> Sequence[VisualBase]:
                    ...

                # later, if needed
                animation_loop.remove_callback(my_callback)
            ```
        """
        self.add_callback(func)

        def wrapper(delta_time: float) -> Sequence[VisualBase]:
            # print("Before the function runs")
            result = func(delta_time)
            # print("After the function runs")
            return result

        return wrapper

    # =============================================================================
    # .start()
    # =============================================================================
    def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
        """Start the animation loop.

        Begins rendering frames using registered callbacks to update visuals.
        In test mode (GSP_TEST=True), saves a single preview image instead of animating.

        Args:
            viewports: Sequence of viewport regions to render into.
            visuals: Sequence of visual elements to render and animate.
            model_matrices: Sequence of model transformation matrices.
            cameras: Sequence of cameras defining view and projection.
        """
        self._canvas = self._matplotlib_renderer.get_canvas()
        self._viewports = viewports
        self._visuals = visuals
        self._model_matrices = model_matrices
        self._cameras = cameras
        self._time_last_update = time.time()

        # =============================================================================
        # Render the image once
        # =============================================================================

        self._matplotlib_renderer.render(viewports, visuals, model_matrices, cameras)

        # =============================================================================
        # Handle GSP_TEST=True
        # =============================================================================

        # detect if we are in not interactive mode - used during testing
        in_test = "GSP_TEST" in os.environ and os.environ["GSP_TEST"] == "True"

        # if we are not in interactive mode, save a preview image and return
        if in_test == True:
            # notify all animator callbacks
            changed_visuals: list[VisualBase] = []
            for animator_callback in self._callbacks:
                _changed_visuals = animator_callback(1.0 / self._fps)
                changed_visuals.extend(_changed_visuals)

            # render the scene to get the new image
            image_png_data = self._matplotlib_renderer.render(viewports, visuals, model_matrices, cameras, return_image=True, image_format="png")
            # get the main script name
            main_script_name = os.path.basename(__main__.__file__) if hasattr(__main__, "__file__") else "interactive"
            main_script_basename = os.path.splitext(main_script_name)[0]
            # buid the output image path
            image_path = os.path.join(__dirname__, "../../../examples/output", f"{main_script_basename}_animator_matplotlib.png")
            image_path = os.path.abspath(image_path)
            # save image_png_data in a image file
            with open(image_path, "wb") as image_file:
                image_file.write(image_png_data)
            # log the event
            print(f"Saved animation preview image to: {image_path}")
            return

        # NOTE: here we are in interactive mode!!

        # =============================================================================
        # Initialize the animation
        # =============================================================================

        figure = self._matplotlib_renderer.get_mpl_figure()
        self._funcAnimation = matplotlib.animation.FuncAnimation(
            figure, self._mpl_animate, frames=int(self._video_duration * self._fps), interval=1000.0 / self._fps
        )

        # save the animation if a path is provided
        if self._video_path is not None:
            self._funcAnimation.save(self._video_path, writer=self._video_writer, fps=self._fps)
            # Dispatch the video saved event
            self.on_video_saved.dispatch()

        # =============================================================================
        # Show the animation
        # =============================================================================

        self._matplotlib_renderer.show()

    # =============================================================================
    # .stop()
    # =============================================================================
    def stop(self):
        """Stop the animation loop.

        Stops the Matplotlib animation timer and clears internal state.
        """
        self._canvas = None
        self._viewports = None
        self._time_last_update = None

        # stop the animation function timer
        if self._funcAnimation is not None:
            self._funcAnimation.event_source.stop()
            self._funcAnimation = None

    # =============================================================================
    # ._mpl_animate()
    # =============================================================================

    def _mpl_animate(self, frame_index: int) -> list[matplotlib.artist.Artist]:
        """Internal callback for Matplotlib animation.

        Called by Matplotlib's FuncAnimation on each frame to update the display.
        Notifies all registered callbacks and re-renders changed visuals.

        Args:
            frame_index: The current frame number in the animation sequence.

        Returns:
            List of Matplotlib artists that were updated during this frame.
        """
        # sanity checks
        assert self._canvas is not None, "Canvas MUST be set during the animation"
        assert self._viewports is not None, "Viewports MUST be set during the animation"
        assert self._visuals is not None, "Visuals MUST be set during the animation"
        assert self._model_matrices is not None, "Model matrices MUST be set during the animation"
        assert self._cameras is not None, "Cameras MUST be set during the animation"

        # compute delta time
        present = time.time()
        delta_time = (present - self._time_last_update) if self._time_last_update is not None else (1 / self._fps)
        self._time_last_update = present

        # notify all animator callbacks
        changed_visuals: list[VisualBase] = []
        for callback in self._callbacks:
            _changed_visuals = callback(delta_time)
            changed_visuals.extend(_changed_visuals)

        # Render the scene to update the visuals
        self._matplotlib_renderer.render(self._viewports, self._visuals, self._model_matrices, self._cameras)

        # convert all changed visuals to mpl artists
        changed_mpl_artists: list[matplotlib.artist.Artist] = []
        for visual in changed_visuals:
            mpl_artists = self._get_mpl_artists(self._viewports, self._visuals, visual)
            changed_mpl_artists.extend(mpl_artists)

        # return the changed mpl artists
        return changed_mpl_artists

    # =============================================================================
    # ._get_mpl_artists()
    # =============================================================================

    def _get_mpl_artists(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], visual_base: VisualBase) -> list[matplotlib.artist.Artist]:
        """Get the Matplotlib artists corresponding to a given visual.

        This is needed for Matplotlib's FuncAnimation to update only the relevant artists
        that have changed, improving rendering performance.

        Args:
            viewports: Sequence of all viewports in the scene.
            visuals: Sequence of all visuals in the scene.
            visual_base: The specific visual to get artists for.

        Returns:
            List of Matplotlib Artist objects corresponding to the visual.

        Raises:
            NotImplementedError: If the visual type is not supported.
        """
        mpl_artists: list[matplotlib.artist.Artist] = []

        # Find the index of the visual in the visuals list
        visual_index = visuals.index(visual_base)
        visual = visuals[visual_index]
        viewport = viewports[visual_index]

        if isinstance(visual, Pixels):
            pixels: Pixels = visual
            positions_buffer = TransBufUtils.to_buffer(pixels.get_positions())
            group_count = GroupUtils.get_group_count(positions_buffer.get_count(), pixels.get_groups())
            artist_uuid_prefix = f"{viewport.get_uuid()}_{visual.get_uuid()}"
            for group_index in range(group_count):
                group_uuid = f"{artist_uuid_prefix}_group_{group_index}"
                mpl_artists.append(self._matplotlib_renderer._artists[group_uuid])
        elif isinstance(visual, Points):
            points: Points = visual
            artist_uuid = f"{viewport.get_uuid()}_{points.get_uuid()}"
            mpl_artist = self._matplotlib_renderer._artists[artist_uuid]
            mpl_artists.append(mpl_artist)
        elif isinstance(visual, Texts):
            texts: Texts = visual
            for text_index in range(len(texts.get_strings())):
                artist_uuid = f"{viewport.get_uuid()}_{texts.get_uuid()}_{text_index}"
                mpl_artist = self._matplotlib_renderer._artists[artist_uuid]
                mpl_artists.append(mpl_artist)
        else:
            raise NotImplementedError(f"Getting mpl artists for visual type {type(visual)} is not implemented.")

        return mpl_artists

on_video_saved = Event[VideoSavedCalledback]() instance-attribute

Event triggered when the video has been successfully saved to disk.

__init__(matplotlib_renderer: MatplotlibRenderer, fps: int = 50, video_duration: float = 10.0, video_path: str | None = None, video_writer: str | None = None)

Initialize the Matplotlib animator.

Parameters:

Name Type Description Default
matplotlib_renderer gsp_matplotlib.renderer.MatplotlibRenderer

The Matplotlib renderer to use for rendering frames.

required
fps int

Target frames per second for the animation.

50
video_duration float

Total duration of the animation in seconds.

10.0
video_path str | None

Path where the video should be saved. If None, no video is saved.

None
video_writer str | None

Video writer to use ("ffmpeg" or "pillow"). If None, auto-detected from extension.

None

Raises:

Type Description
ValueError

If the video format is not supported.

Source code in src/gsp_extra/animator/animator_matplotlib.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def __init__(
    self,
    matplotlib_renderer: MatplotlibRenderer,
    fps: int = 50,
    video_duration: float = 10.0,
    video_path: str | None = None,
    video_writer: str | None = None,
):
    """Initialize the Matplotlib animator.

    Args:
        matplotlib_renderer: The Matplotlib renderer to use for rendering frames.
        fps: Target frames per second for the animation.
        video_duration: Total duration of the animation in seconds.
        video_path: Path where the video should be saved. If None, no video is saved.
        video_writer: Video writer to use ("ffmpeg" or "pillow"). If None, auto-detected from extension.

    Raises:
        ValueError: If the video format is not supported.
    """
    self._callbacks: list[AnimatorFunc] = []
    self._matplotlib_renderer = matplotlib_renderer
    self._fps = fps
    self._video_duration = video_duration
    self._video_path = video_path
    self._video_writer: str | None = None
    self._time_last_update: float | None = None

    self._funcAnimation: matplotlib.animation.FuncAnimation | None = None

    self._canvas: Canvas | None = None
    self._viewports: Sequence[Viewport] | None = None
    self._visuals: Sequence[VisualBase] | None = None
    self._model_matrices: Sequence[TransBuf] | None = None
    self._cameras: Sequence[Camera] | None = None

    self.on_video_saved = Event[VideoSavedCalledback]()
    """Event triggered when the video has been successfully saved to disk."""

    # guess the video writer from the file extension if not provided
    if self._video_path is not None:
        if video_writer is not None:
            self._video_writer = video_writer
        else:
            video_ext = os.path.splitext(self._video_path)[1].lower()
            if video_ext in [".mp4", ".m4v", ".mov"]:
                self._video_writer = "ffmpeg"
            elif video_ext in [".gif", ".apng", ".webp"]:
                self._video_writer = "pillow"
            else:
                raise ValueError(f"Unsupported video format: {video_ext}")

add_callback(func: AnimatorFunc) -> None

Add a callback to the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The animator function to call on each frame.

required
Source code in src/gsp_extra/animator/animator_matplotlib.py
101
102
103
104
105
106
107
def add_callback(self, func: AnimatorFunc) -> None:
    """Add a callback to the animation loop.

    Args:
        func: The animator function to call on each frame.
    """
    self._callbacks.append(func)

event_listener(func: AnimatorFunc) -> AnimatorFunc

Decorator to add a callback to the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The animator function to register as a callback.

required

Returns:

Type Description
gsp_extra.animator.animator_types.AnimatorFunc

The wrapped animator function.

Usage
    @animation_loop.event_listener
    def my_callback(delta_time: float) -> Sequence[VisualBase]:
        ...

    # later, if needed
    animation_loop.remove_callback(my_callback)
Source code in src/gsp_extra/animator/animator_matplotlib.py
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
def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
    """Decorator to add a callback to the animation loop.

    Args:
        func: The animator function to register as a callback.

    Returns:
        The wrapped animator function.

    Usage:
        ```python
            @animation_loop.event_listener
            def my_callback(delta_time: float) -> Sequence[VisualBase]:
                ...

            # later, if needed
            animation_loop.remove_callback(my_callback)
        ```
    """
    self.add_callback(func)

    def wrapper(delta_time: float) -> Sequence[VisualBase]:
        # print("Before the function runs")
        result = func(delta_time)
        # print("After the function runs")
        return result

    return wrapper

remove_callback(func: AnimatorFunc) -> None

Remove a callback from the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The animator function to remove.

required
Source code in src/gsp_extra/animator/animator_matplotlib.py
109
110
111
112
113
114
115
def remove_callback(self, func: AnimatorFunc) -> None:
    """Remove a callback from the animation loop.

    Args:
        func: The animator function to remove.
    """
    self._callbacks.remove(func)

start(viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None

Start the animation loop.

Begins rendering frames using registered callbacks to update visuals. In test mode (GSP_TEST=True), saves a single preview image instead of animating.

Parameters:

Name Type Description Default
viewports typing.Sequence[gsp.core.viewport.Viewport]

Sequence of viewport regions to render into.

required
visuals typing.Sequence[gsp.types.visual_base.VisualBase]

Sequence of visual elements to render and animate.

required
model_matrices typing.Sequence[gsp.types.transbuf.TransBuf]

Sequence of model transformation matrices.

required
cameras typing.Sequence[gsp.core.camera.Camera]

Sequence of cameras defining view and projection.

required
Source code in src/gsp_extra/animator/animator_matplotlib.py
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
191
192
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
221
222
223
224
225
def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
    """Start the animation loop.

    Begins rendering frames using registered callbacks to update visuals.
    In test mode (GSP_TEST=True), saves a single preview image instead of animating.

    Args:
        viewports: Sequence of viewport regions to render into.
        visuals: Sequence of visual elements to render and animate.
        model_matrices: Sequence of model transformation matrices.
        cameras: Sequence of cameras defining view and projection.
    """
    self._canvas = self._matplotlib_renderer.get_canvas()
    self._viewports = viewports
    self._visuals = visuals
    self._model_matrices = model_matrices
    self._cameras = cameras
    self._time_last_update = time.time()

    # =============================================================================
    # Render the image once
    # =============================================================================

    self._matplotlib_renderer.render(viewports, visuals, model_matrices, cameras)

    # =============================================================================
    # Handle GSP_TEST=True
    # =============================================================================

    # detect if we are in not interactive mode - used during testing
    in_test = "GSP_TEST" in os.environ and os.environ["GSP_TEST"] == "True"

    # if we are not in interactive mode, save a preview image and return
    if in_test == True:
        # notify all animator callbacks
        changed_visuals: list[VisualBase] = []
        for animator_callback in self._callbacks:
            _changed_visuals = animator_callback(1.0 / self._fps)
            changed_visuals.extend(_changed_visuals)

        # render the scene to get the new image
        image_png_data = self._matplotlib_renderer.render(viewports, visuals, model_matrices, cameras, return_image=True, image_format="png")
        # get the main script name
        main_script_name = os.path.basename(__main__.__file__) if hasattr(__main__, "__file__") else "interactive"
        main_script_basename = os.path.splitext(main_script_name)[0]
        # buid the output image path
        image_path = os.path.join(__dirname__, "../../../examples/output", f"{main_script_basename}_animator_matplotlib.png")
        image_path = os.path.abspath(image_path)
        # save image_png_data in a image file
        with open(image_path, "wb") as image_file:
            image_file.write(image_png_data)
        # log the event
        print(f"Saved animation preview image to: {image_path}")
        return

    # NOTE: here we are in interactive mode!!

    # =============================================================================
    # Initialize the animation
    # =============================================================================

    figure = self._matplotlib_renderer.get_mpl_figure()
    self._funcAnimation = matplotlib.animation.FuncAnimation(
        figure, self._mpl_animate, frames=int(self._video_duration * self._fps), interval=1000.0 / self._fps
    )

    # save the animation if a path is provided
    if self._video_path is not None:
        self._funcAnimation.save(self._video_path, writer=self._video_writer, fps=self._fps)
        # Dispatch the video saved event
        self.on_video_saved.dispatch()

    # =============================================================================
    # Show the animation
    # =============================================================================

    self._matplotlib_renderer.show()

stop()

Stop the animation loop.

Stops the Matplotlib animation timer and clears internal state.

Source code in src/gsp_extra/animator/animator_matplotlib.py
230
231
232
233
234
235
236
237
238
239
240
241
242
def stop(self):
    """Stop the animation loop.

    Stops the Matplotlib animation timer and clears internal state.
    """
    self._canvas = None
    self._viewports = None
    self._time_last_update = None

    # stop the animation function timer
    if self._funcAnimation is not None:
        self._funcAnimation.event_source.stop()
        self._funcAnimation = None

Animator Datoviz

gsp_extra.animator.animator_datoviz

Animator for GSP scenes using a matplotlib renderer.

AnimatorDatoviz

Bases: gsp_extra.animator.animator_base.AnimatorBase

Animator for GSP scenes using a matplotlib renderer.

Source code in src/gsp_extra/animator/animator_datoviz.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
193
class AnimatorDatoviz(AnimatorBase):
    """Animator for GSP scenes using a matplotlib renderer."""

    def __init__(
        self,
        datoviz_renderer: DatovizRenderer,
        fps: int = 50,
        video_duration: float | None = None,
        video_path: str | None = None,
    ):
        """Initialize the animator.

        Args:
            datoviz_renderer (DatovizRenderer): The datoviz renderer to use for rendering.
            fps (int, optional): Frames per second. Defaults to 50.
            video_duration (float | None, optional): Duration of the video to save. Defaults to None.
            video_path (str | None, optional): Path to save the video. Defaults to None.
        """
        self._callbacks: list[AnimatorFunc] = []
        self._datoviz_renderer = datoviz_renderer
        self._fps = fps

        # sanity check - video not supported yet
        assert video_duration is None, "GspAnimatorDatoviz does not support video saving yet."
        assert video_path is None, "GspAnimatorDatoviz does not support video saving yet."

        self._canvas: Canvas | None = None
        self._viewports: Sequence[Viewport] | None = None
        self._visuals: Sequence[VisualBase] | None = None
        self._model_matrices: Sequence[TransBuf] | None = None
        self._cameras: Sequence[Camera] | None = None

        self.on_video_saved = Event[VideoSavedCalledback]()
        """Event triggered when the video is saved."""

    # =============================================================================
    # .add_callback/.remove_callback/.decorator
    # =============================================================================

    def add_callback(self, func: AnimatorFunc) -> None:
        """Add a callback to the animation loop."""
        self._callbacks.append(func)

    def remove_callback(self, func: AnimatorFunc) -> None:
        """Remove a callback from the animation loop."""
        self._callbacks.remove(func)

    def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
        """A decorator to add a callback to the animation loop.

        Usage:
            ```python
                @animation_loop.event_listener
                def my_callback(delta_time: float) -> Sequence[Object3D]:
                    ...

                # later, if needed
                animation_loop.remove_callback(my_callback)
            ```
        """
        self.add_callback(func)

        def wrapper(delta_time: float) -> Sequence[VisualBase]:
            # print("Before the function runs")
            result = func(delta_time)
            # print("After the function runs")
            return result

        return wrapper

    # =============================================================================
    # .start()
    # =============================================================================
    def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
        """Animate the given canvas and camera using the provided callbacks to update visuals."""
        self._canvas = self._datoviz_renderer.get_canvas()
        self._viewports = viewports
        self._visuals = visuals
        self._model_matrices = model_matrices
        self._cameras = cameras
        self._time_last_update = time.time()

        # =============================================================================
        # Render the image once
        # =============================================================================

        self._datoviz_renderer.render(viewports, visuals, model_matrices, cameras)

        # =============================================================================
        # Handle GSP_TEST=True
        # =============================================================================

        # detect if we are in not interactive mode - used during testing
        in_test = "GSP_TEST" in os.environ and os.environ["GSP_TEST"] == "True"

        # if we are not in interactive mode, save a preview image and return
        if in_test == True:
            # notify all animator callbacks
            changed_visuals: list[VisualBase] = []
            for animator_callback in self._callbacks:
                _changed_visuals = animator_callback(1.0 / self._fps)
                changed_visuals.extend(_changed_visuals)

            # render the scene to get the new image
            image_png_data = self._datoviz_renderer.render(viewports, visuals, model_matrices, cameras, return_image=True, image_format="png")
            # get the main script name
            main_script_name = os.path.basename(__main__.__file__) if hasattr(__main__, "__file__") else "interactive"
            main_script_basename = os.path.splitext(main_script_name)[0]
            # buid the output image path
            image_path = os.path.join(__dirname__, "../../../examples/output", f"{main_script_basename}_animator_datoviz.png")
            image_path = os.path.abspath(image_path)
            # save image_png_data in a image file
            with open(image_path, "wb") as image_file:
                image_file.write(image_png_data)
            # log the event
            print(f"Saved animation preview image to: {image_path}")
            return

        # NOTE: here we are in interactive mode!!

        # =============================================================================
        # Initialize the animation
        # =============================================================================

        dvz_app = self._datoviz_renderer.get_dvz_app()

        @dvz_app.timer(period=1.0 / self._fps)
        def on_timer(event):
            self._dvz_animate()

        # =============================================================================
        # Show the animation
        # =============================================================================

        self._datoviz_renderer.show()

    # =============================================================================
    # .stop()
    # =============================================================================
    def stop(self):
        """Stop the animation."""
        self._canvas = None
        self._viewports = None
        self._time_last_update = None

        warning.warn("GspAnimatorDatoviz.stop() is not fully implemented yet.")

    def _dvz_animate(self) -> None:
        # sanity checks
        assert self._canvas is not None, "Canvas MUST be set during the animation"
        assert self._viewports is not None, "Viewports MUST be set during the animation"
        assert self._visuals is not None, "Visuals MUST be set during the animation"
        assert self._model_matrices is not None, "Model matrices MUST be set during the animation"
        assert self._cameras is not None, "Cameras MUST be set during the animation"

        # compute delta time
        present = time.time()
        delta_time = (present - self._time_last_update) if self._time_last_update is not None else (1 / self._fps)
        self._time_last_update = present

        # notify all animator callbacks
        changed_visuals: list[VisualBase] = []
        for callback in self._callbacks:
            _changed_visuals = callback(delta_time)
            changed_visuals.extend(_changed_visuals)

        # changed_visuals is not used by datoviz, but could be used in the future

        # Render the scene to update the visuals
        self._datoviz_renderer.render(self._viewports, self._visuals, self._model_matrices, self._cameras, return_image=False)

on_video_saved = Event[VideoSavedCalledback]() instance-attribute

Event triggered when the video is saved.

__init__(datoviz_renderer: DatovizRenderer, fps: int = 50, video_duration: float | None = None, video_path: str | None = None)

Initialize the animator.

Parameters:

Name Type Description Default
datoviz_renderer gsp_datoviz.renderer.datoviz_renderer.DatovizRenderer

The datoviz renderer to use for rendering.

required
fps int

Frames per second. Defaults to 50.

50
video_duration float | None

Duration of the video to save. Defaults to None.

None
video_path str | None

Path to save the video. Defaults to None.

None
Source code in src/gsp_extra/animator/animator_datoviz.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    datoviz_renderer: DatovizRenderer,
    fps: int = 50,
    video_duration: float | None = None,
    video_path: str | None = None,
):
    """Initialize the animator.

    Args:
        datoviz_renderer (DatovizRenderer): The datoviz renderer to use for rendering.
        fps (int, optional): Frames per second. Defaults to 50.
        video_duration (float | None, optional): Duration of the video to save. Defaults to None.
        video_path (str | None, optional): Path to save the video. Defaults to None.
    """
    self._callbacks: list[AnimatorFunc] = []
    self._datoviz_renderer = datoviz_renderer
    self._fps = fps

    # sanity check - video not supported yet
    assert video_duration is None, "GspAnimatorDatoviz does not support video saving yet."
    assert video_path is None, "GspAnimatorDatoviz does not support video saving yet."

    self._canvas: Canvas | None = None
    self._viewports: Sequence[Viewport] | None = None
    self._visuals: Sequence[VisualBase] | None = None
    self._model_matrices: Sequence[TransBuf] | None = None
    self._cameras: Sequence[Camera] | None = None

    self.on_video_saved = Event[VideoSavedCalledback]()
    """Event triggered when the video is saved."""

add_callback(func: AnimatorFunc) -> None

Add a callback to the animation loop.

Source code in src/gsp_extra/animator/animator_datoviz.py
63
64
65
def add_callback(self, func: AnimatorFunc) -> None:
    """Add a callback to the animation loop."""
    self._callbacks.append(func)

event_listener(func: AnimatorFunc) -> AnimatorFunc

A decorator to add a callback to the animation loop.

Usage
    @animation_loop.event_listener
    def my_callback(delta_time: float) -> Sequence[Object3D]:
        ...

    # later, if needed
    animation_loop.remove_callback(my_callback)
Source code in src/gsp_extra/animator/animator_datoviz.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
    """A decorator to add a callback to the animation loop.

    Usage:
        ```python
            @animation_loop.event_listener
            def my_callback(delta_time: float) -> Sequence[Object3D]:
                ...

            # later, if needed
            animation_loop.remove_callback(my_callback)
        ```
    """
    self.add_callback(func)

    def wrapper(delta_time: float) -> Sequence[VisualBase]:
        # print("Before the function runs")
        result = func(delta_time)
        # print("After the function runs")
        return result

    return wrapper

remove_callback(func: AnimatorFunc) -> None

Remove a callback from the animation loop.

Source code in src/gsp_extra/animator/animator_datoviz.py
67
68
69
def remove_callback(self, func: AnimatorFunc) -> None:
    """Remove a callback from the animation loop."""
    self._callbacks.remove(func)

start(viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None

Animate the given canvas and camera using the provided callbacks to update visuals.

Source code in src/gsp_extra/animator/animator_datoviz.py
 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 start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
    """Animate the given canvas and camera using the provided callbacks to update visuals."""
    self._canvas = self._datoviz_renderer.get_canvas()
    self._viewports = viewports
    self._visuals = visuals
    self._model_matrices = model_matrices
    self._cameras = cameras
    self._time_last_update = time.time()

    # =============================================================================
    # Render the image once
    # =============================================================================

    self._datoviz_renderer.render(viewports, visuals, model_matrices, cameras)

    # =============================================================================
    # Handle GSP_TEST=True
    # =============================================================================

    # detect if we are in not interactive mode - used during testing
    in_test = "GSP_TEST" in os.environ and os.environ["GSP_TEST"] == "True"

    # if we are not in interactive mode, save a preview image and return
    if in_test == True:
        # notify all animator callbacks
        changed_visuals: list[VisualBase] = []
        for animator_callback in self._callbacks:
            _changed_visuals = animator_callback(1.0 / self._fps)
            changed_visuals.extend(_changed_visuals)

        # render the scene to get the new image
        image_png_data = self._datoviz_renderer.render(viewports, visuals, model_matrices, cameras, return_image=True, image_format="png")
        # get the main script name
        main_script_name = os.path.basename(__main__.__file__) if hasattr(__main__, "__file__") else "interactive"
        main_script_basename = os.path.splitext(main_script_name)[0]
        # buid the output image path
        image_path = os.path.join(__dirname__, "../../../examples/output", f"{main_script_basename}_animator_datoviz.png")
        image_path = os.path.abspath(image_path)
        # save image_png_data in a image file
        with open(image_path, "wb") as image_file:
            image_file.write(image_png_data)
        # log the event
        print(f"Saved animation preview image to: {image_path}")
        return

    # NOTE: here we are in interactive mode!!

    # =============================================================================
    # Initialize the animation
    # =============================================================================

    dvz_app = self._datoviz_renderer.get_dvz_app()

    @dvz_app.timer(period=1.0 / self._fps)
    def on_timer(event):
        self._dvz_animate()

    # =============================================================================
    # Show the animation
    # =============================================================================

    self._datoviz_renderer.show()

stop()

Stop the animation.

Source code in src/gsp_extra/animator/animator_datoviz.py
163
164
165
166
167
168
169
def stop(self):
    """Stop the animation."""
    self._canvas = None
    self._viewports = None
    self._time_last_update = None

    warning.warn("GspAnimatorDatoviz.stop() is not fully implemented yet.")

Animator Network

gsp_extra.animator.animator_network

Network-based animator for GSP scenes.

Provides animation capabilities using a NetworkRenderer backend with support for real-time animation display and video export.

AnimatorNetwork

Bases: gsp_extra.animator.animator_base.AnimatorBase

Animator for GSP scenes using a network renderer.

Manages animation loops with callback functions that update visuals each frame. Supports real-time display and video export in various formats.

Source code in src/gsp_extra/animator/animator_network.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
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
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
270
271
272
273
274
275
class AnimatorNetwork(AnimatorBase):
    """Animator for GSP scenes using a network renderer.

    Manages animation loops with callback functions that update visuals each frame.
    Supports real-time display and video export in various formats.
    """

    def __init__(
        self,
        network_renderer: NetworkRenderer,
        fps: int = 50,
        video_duration: float = 10.0,
        video_path: str | None = None,
        video_writer: str | None = None,
    ):
        """Initialize the network animator.

        Args:
            network_renderer: The network renderer to use for rendering frames.
            fps: Target frames per second for the animation.
            video_duration: Total duration of the animation in seconds.
            video_path: Path where the video should be saved. If None, no video is saved.
            video_writer: Video writer to use ("ffmpeg" or "pillow"). If None, auto-detected from extension.

        Raises:
            ValueError: If the video format is not supported.
        """
        self._callbacks: list[AnimatorFunc] = []
        self._network_renderer = network_renderer
        self._fps = fps
        self._video_duration = video_duration
        self._video_path = video_path
        self._video_writer: str | None = video_writer
        self._time_last_update: float | None = None

        self._funcAnimation: matplotlib.animation.FuncAnimation | None = None

        self._canvas: Canvas | None = None
        self._viewports: Sequence[Viewport] | None = None
        self._visuals: Sequence[VisualBase] | None = None
        self._model_matrices: Sequence[TransBuf] | None = None
        self._cameras: Sequence[Camera] | None = None

        self.on_video_saved = Event[VideoSavedCalledback]()
        """Event triggered when the video has been successfully saved to disk."""

        # guess the video writer from the file extension if not provided
        if self._video_path is not None:
            if video_writer is not None:
                self._video_writer = video_writer
            else:
                video_ext = os.path.splitext(self._video_path)[1].lower()
                if video_ext in [".mp4", ".m4v", ".mov"]:
                    self._video_writer = "ffmpeg"
                elif video_ext in [".gif", ".apng", ".webp"]:
                    self._video_writer = "pillow"
                else:
                    raise ValueError(f"Unsupported video format: {video_ext}")

    # =============================================================================
    # .add_callback/.remove_callback/.decorator
    # =============================================================================

    def add_callback(self, func: AnimatorFunc) -> None:
        """Add a callback to the animation loop.

        Args:
            func: The animator function to call on each frame.
        """
        self._callbacks.append(func)

    def remove_callback(self, func: AnimatorFunc) -> None:
        """Remove a callback from the animation loop.

        Args:
            func: The animator function to remove.
        """
        self._callbacks.remove(func)

    def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
        """Decorator to add a callback to the animation loop.

        Args:
            func: The animator function to register as a callback.

        Returns:
            The wrapped animator function.

        Usage:
            ```python
                @animation_loop.event_listener
                def my_callback(delta_time: float) -> Sequence[VisualBase]:
                    ...

                # later, if needed
                animation_loop.remove_callback(my_callback)
            ```
        """
        self.add_callback(func)

        def wrapper(delta_time: float) -> Sequence[VisualBase]:
            # print("Before the function runs")
            result = func(delta_time)
            # print("After the function runs")
            return result

        return wrapper

    # =============================================================================
    # .start()
    # =============================================================================
    def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
        """Start the animation loop.

        Begins rendering frames using registered callbacks to update visuals.
        In test mode (GSP_TEST=True), saves a single preview image instead of animating.

        Args:
            viewports: Sequence of viewport regions to render into.
            visuals: Sequence of visual elements to render and animate.
            model_matrices: Sequence of model transformation matrices.
            cameras: Sequence of cameras defining view and projection.
        """
        self._canvas = self._network_renderer.get_canvas()
        self._viewports = viewports
        self._visuals = visuals
        self._model_matrices = model_matrices
        self._cameras = cameras
        self._time_last_update = time.time()

        # =============================================================================
        # Render the image once
        # =============================================================================

        self._network_renderer.render(viewports, visuals, model_matrices, cameras)

        # =============================================================================
        # Handle GSP_TEST=True
        # =============================================================================

        # detect if we are in not interactive mode - used during testing
        in_test = "GSP_TEST" in os.environ and os.environ["GSP_TEST"] == "True"

        # if we are not in interactive mode, save a preview image and return
        if in_test == True:
            # notify all animator callbacks
            changed_visuals: list[VisualBase] = []
            for animator_callback in self._callbacks:
                _changed_visuals = animator_callback(1.0 / self._fps)
                changed_visuals.extend(_changed_visuals)

            # render the scene to get the new image
            image_png_data = self._network_renderer.render(viewports, visuals, model_matrices, cameras)
            # get the main script name
            main_script_name = os.path.basename(__main__.__file__) if hasattr(__main__, "__file__") else "interactive"
            main_script_basename = os.path.splitext(main_script_name)[0]
            # buid the output image path
            image_path = os.path.join(__dirname__, "../../../examples/output", f"{main_script_basename}_animator_network.png")
            image_path = os.path.abspath(image_path)
            # save image_png_data in a image file
            with open(image_path, "wb") as image_file:
                image_file.write(image_png_data)
            # log the event
            print(f"Saved animation preview image to: {image_path}")
            return

        # NOTE: here we are in interactive mode!!

        # =============================================================================
        # Initialize the animation
        # =============================================================================

        figure = self._network_renderer.get_mpl_figure()
        self._funcAnimation = matplotlib.animation.FuncAnimation(
            figure, self._mpl_animate, frames=int(self._video_duration * self._fps), interval=1000.0 / self._fps
        )

        # save the animation if a path is provided
        if self._video_path is not None:
            self._funcAnimation.save(self._video_path, writer=self._video_writer, fps=self._fps)
            # Dispatch the video saved event
            self.on_video_saved.dispatch()

        # =============================================================================
        # Show the animation
        # =============================================================================

        self._network_renderer.show()

    # =============================================================================
    # .stop()
    # =============================================================================
    def stop(self):
        """Stop the animation loop.

        Stops the Matplotlib animation timer and clears internal state.
        """
        self._canvas = None
        self._viewports = None
        self._time_last_update = None

        # stop the animation function timer
        if self._funcAnimation is not None:
            self._funcAnimation.event_source.stop()
            self._funcAnimation = None

    # =============================================================================
    # ._mpl_animate()
    # =============================================================================

    def _mpl_animate(self, frame_index: int) -> list[matplotlib.artist.Artist]:
        """Internal callback for Matplotlib animation.

        Called by Matplotlib's FuncAnimation on each frame to update the display.

        Args:
            frame_index: The current frame number in the animation sequence.

        Returns:
            List of Matplotlib artists that were updated.
        """
        # sanity checks
        assert self._canvas is not None, "Canvas MUST be set during the animation"
        assert self._viewports is not None, "Viewports MUST be set during the animation"
        assert self._visuals is not None, "Visuals MUST be set during the animation"
        assert self._model_matrices is not None, "Model matrices MUST be set during the animation"
        assert self._cameras is not None, "Cameras MUST be set during the animation"

        # compute delta time
        present = time.time()
        delta_time = (present - self._time_last_update) if self._time_last_update is not None else (1 / self._fps)
        self._time_last_update = present

        # notify all animator callbacks
        changed_visuals: list[VisualBase] = []
        for callback in self._callbacks:
            _changed_visuals = callback(delta_time)
            changed_visuals.extend(_changed_visuals)

        # Render the scene to update the visuals
        if len(changed_visuals) > 0:
            self._network_renderer.render(self._viewports, self._visuals, self._model_matrices, self._cameras)

        # return the changed mpl artists
        return [self._network_renderer._axes_image]

on_video_saved = Event[VideoSavedCalledback]() instance-attribute

Event triggered when the video has been successfully saved to disk.

__init__(network_renderer: NetworkRenderer, fps: int = 50, video_duration: float = 10.0, video_path: str | None = None, video_writer: str | None = None)

Initialize the network animator.

Parameters:

Name Type Description Default
network_renderer gsp_network.renderer.NetworkRenderer

The network renderer to use for rendering frames.

required
fps int

Target frames per second for the animation.

50
video_duration float

Total duration of the animation in seconds.

10.0
video_path str | None

Path where the video should be saved. If None, no video is saved.

None
video_writer str | None

Video writer to use ("ffmpeg" or "pillow"). If None, auto-detected from extension.

None

Raises:

Type Description
ValueError

If the video format is not supported.

Source code in src/gsp_extra/animator/animator_network.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __init__(
    self,
    network_renderer: NetworkRenderer,
    fps: int = 50,
    video_duration: float = 10.0,
    video_path: str | None = None,
    video_writer: str | None = None,
):
    """Initialize the network animator.

    Args:
        network_renderer: The network renderer to use for rendering frames.
        fps: Target frames per second for the animation.
        video_duration: Total duration of the animation in seconds.
        video_path: Path where the video should be saved. If None, no video is saved.
        video_writer: Video writer to use ("ffmpeg" or "pillow"). If None, auto-detected from extension.

    Raises:
        ValueError: If the video format is not supported.
    """
    self._callbacks: list[AnimatorFunc] = []
    self._network_renderer = network_renderer
    self._fps = fps
    self._video_duration = video_duration
    self._video_path = video_path
    self._video_writer: str | None = video_writer
    self._time_last_update: float | None = None

    self._funcAnimation: matplotlib.animation.FuncAnimation | None = None

    self._canvas: Canvas | None = None
    self._viewports: Sequence[Viewport] | None = None
    self._visuals: Sequence[VisualBase] | None = None
    self._model_matrices: Sequence[TransBuf] | None = None
    self._cameras: Sequence[Camera] | None = None

    self.on_video_saved = Event[VideoSavedCalledback]()
    """Event triggered when the video has been successfully saved to disk."""

    # guess the video writer from the file extension if not provided
    if self._video_path is not None:
        if video_writer is not None:
            self._video_writer = video_writer
        else:
            video_ext = os.path.splitext(self._video_path)[1].lower()
            if video_ext in [".mp4", ".m4v", ".mov"]:
                self._video_writer = "ffmpeg"
            elif video_ext in [".gif", ".apng", ".webp"]:
                self._video_writer = "pillow"
            else:
                raise ValueError(f"Unsupported video format: {video_ext}")

add_callback(func: AnimatorFunc) -> None

Add a callback to the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The animator function to call on each frame.

required
Source code in src/gsp_extra/animator/animator_network.py
 94
 95
 96
 97
 98
 99
100
def add_callback(self, func: AnimatorFunc) -> None:
    """Add a callback to the animation loop.

    Args:
        func: The animator function to call on each frame.
    """
    self._callbacks.append(func)

event_listener(func: AnimatorFunc) -> AnimatorFunc

Decorator to add a callback to the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The animator function to register as a callback.

required

Returns:

Type Description
gsp_extra.animator.animator_types.AnimatorFunc

The wrapped animator function.

Usage
    @animation_loop.event_listener
    def my_callback(delta_time: float) -> Sequence[VisualBase]:
        ...

    # later, if needed
    animation_loop.remove_callback(my_callback)
Source code in src/gsp_extra/animator/animator_network.py
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
def event_listener(self, func: AnimatorFunc) -> AnimatorFunc:
    """Decorator to add a callback to the animation loop.

    Args:
        func: The animator function to register as a callback.

    Returns:
        The wrapped animator function.

    Usage:
        ```python
            @animation_loop.event_listener
            def my_callback(delta_time: float) -> Sequence[VisualBase]:
                ...

            # later, if needed
            animation_loop.remove_callback(my_callback)
        ```
    """
    self.add_callback(func)

    def wrapper(delta_time: float) -> Sequence[VisualBase]:
        # print("Before the function runs")
        result = func(delta_time)
        # print("After the function runs")
        return result

    return wrapper

remove_callback(func: AnimatorFunc) -> None

Remove a callback from the animation loop.

Parameters:

Name Type Description Default
func gsp_extra.animator.animator_types.AnimatorFunc

The animator function to remove.

required
Source code in src/gsp_extra/animator/animator_network.py
102
103
104
105
106
107
108
def remove_callback(self, func: AnimatorFunc) -> None:
    """Remove a callback from the animation loop.

    Args:
        func: The animator function to remove.
    """
    self._callbacks.remove(func)

start(viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None

Start the animation loop.

Begins rendering frames using registered callbacks to update visuals. In test mode (GSP_TEST=True), saves a single preview image instead of animating.

Parameters:

Name Type Description Default
viewports typing.Sequence[gsp.core.viewport.Viewport]

Sequence of viewport regions to render into.

required
visuals typing.Sequence[gsp.types.visual_base.VisualBase]

Sequence of visual elements to render and animate.

required
model_matrices typing.Sequence[gsp.types.transbuf.TransBuf]

Sequence of model transformation matrices.

required
cameras typing.Sequence[gsp.core.camera.Camera]

Sequence of cameras defining view and projection.

required
Source code in src/gsp_extra/animator/animator_network.py
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
191
192
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
def start(self, viewports: Sequence[Viewport], visuals: Sequence[VisualBase], model_matrices: Sequence[TransBuf], cameras: Sequence[Camera]) -> None:
    """Start the animation loop.

    Begins rendering frames using registered callbacks to update visuals.
    In test mode (GSP_TEST=True), saves a single preview image instead of animating.

    Args:
        viewports: Sequence of viewport regions to render into.
        visuals: Sequence of visual elements to render and animate.
        model_matrices: Sequence of model transformation matrices.
        cameras: Sequence of cameras defining view and projection.
    """
    self._canvas = self._network_renderer.get_canvas()
    self._viewports = viewports
    self._visuals = visuals
    self._model_matrices = model_matrices
    self._cameras = cameras
    self._time_last_update = time.time()

    # =============================================================================
    # Render the image once
    # =============================================================================

    self._network_renderer.render(viewports, visuals, model_matrices, cameras)

    # =============================================================================
    # Handle GSP_TEST=True
    # =============================================================================

    # detect if we are in not interactive mode - used during testing
    in_test = "GSP_TEST" in os.environ and os.environ["GSP_TEST"] == "True"

    # if we are not in interactive mode, save a preview image and return
    if in_test == True:
        # notify all animator callbacks
        changed_visuals: list[VisualBase] = []
        for animator_callback in self._callbacks:
            _changed_visuals = animator_callback(1.0 / self._fps)
            changed_visuals.extend(_changed_visuals)

        # render the scene to get the new image
        image_png_data = self._network_renderer.render(viewports, visuals, model_matrices, cameras)
        # get the main script name
        main_script_name = os.path.basename(__main__.__file__) if hasattr(__main__, "__file__") else "interactive"
        main_script_basename = os.path.splitext(main_script_name)[0]
        # buid the output image path
        image_path = os.path.join(__dirname__, "../../../examples/output", f"{main_script_basename}_animator_network.png")
        image_path = os.path.abspath(image_path)
        # save image_png_data in a image file
        with open(image_path, "wb") as image_file:
            image_file.write(image_png_data)
        # log the event
        print(f"Saved animation preview image to: {image_path}")
        return

    # NOTE: here we are in interactive mode!!

    # =============================================================================
    # Initialize the animation
    # =============================================================================

    figure = self._network_renderer.get_mpl_figure()
    self._funcAnimation = matplotlib.animation.FuncAnimation(
        figure, self._mpl_animate, frames=int(self._video_duration * self._fps), interval=1000.0 / self._fps
    )

    # save the animation if a path is provided
    if self._video_path is not None:
        self._funcAnimation.save(self._video_path, writer=self._video_writer, fps=self._fps)
        # Dispatch the video saved event
        self.on_video_saved.dispatch()

    # =============================================================================
    # Show the animation
    # =============================================================================

    self._network_renderer.show()

stop()

Stop the animation loop.

Stops the Matplotlib animation timer and clears internal state.

Source code in src/gsp_extra/animator/animator_network.py
223
224
225
226
227
228
229
230
231
232
233
234
235
def stop(self):
    """Stop the animation loop.

    Stops the Matplotlib animation timer and clears internal state.
    """
    self._canvas = None
    self._viewports = None
    self._time_last_update = None

    # stop the animation function timer
    if self._funcAnimation is not None:
        self._funcAnimation.event_source.stop()
        self._funcAnimation = None

Animator Types

gsp_extra.animator.animator_types

Type definitions for the animator module.

This module defines callback protocols and type aliases used by the animator for managing animation loops and video saving operations.

AnimatorFunc = Callable[[float], Sequence[VisualBase]] module-attribute

Type alias for animation callback functions.

An animator function is called on each frame of the animation with the elapsed time since the last frame in milliseconds, and returns a sequence of VisualBase objects to render in the current frame.

VideoSavedCalledback

Bases: typing.Protocol

Callback protocol for video saved event.

Source code in src/gsp_extra/animator/animator_types.py
24
25
26
27
28
29
class VideoSavedCalledback(Protocol):
    """Callback protocol for video saved event."""

    def __call__(self) -> None:
        """Called when the video has been successfully saved."""
        ...

__call__() -> None

Called when the video has been successfully saved.

Source code in src/gsp_extra/animator/animator_types.py
27
28
29
def __call__(self) -> None:
    """Called when the video has been successfully saved."""
    ...

Camera Controls Module

Camera control utilities for interactive 3D navigation.

Object Controls AWSD

gsp_extra.camera_controls.object_controls_awsd

Implements camera controls using AWSD keys for movement and mouse for orientation.

ObjectControlAwsd

Implements camera controls using AWSD keys for movement and mouse for orientation.

Source code in src/gsp_extra/camera_controls/object_controls_awsd.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class ObjectControlAwsd:
    """Implements camera controls using AWSD keys for movement and mouse for orientation."""

    def __init__(self, model_matrix_buffer: Buffer, viewport_events: ViewportEventsBase):
        """Initialize the ObjectControlAwsd.

        Args:
            model_matrix_buffer (Buffer): The buffer containing the model matrix to control.
            viewport_events (ViewportEventsBase): The viewport events to subscribe to for keyboard input.
        """
        # sanity checks
        assert model_matrix_buffer.get_type() == BufferType.mat4, "model_matrix must be of type mat4"
        assert model_matrix_buffer.get_count() == 1, "model_matrix must have a count of 1"

        # copy arguments
        self._model_matrix_buffer = model_matrix_buffer
        self._model_matrix_numpy = Bufferx.to_numpy(self._model_matrix_buffer)[0]
        self._viewport_events = viewport_events
        self._speed_x = 0.1
        self._speed_z = 0.1

        # Subscribe to keyboard and mouse events
        self._viewport_events.key_press_event.subscribe(self._on_key_event)
        self._viewport_events.key_release_event.subscribe(self._on_key_event)

    def close(self):
        """Unsubscribe from events."""
        self._viewport_events.key_press_event.unsubscribe(self._on_key_event)
        self._viewport_events.key_release_event.unsubscribe(self._on_key_event)

    def _on_key_event(self, key_event: KeyEvent):
        print(f"ObjectControlAwsd: key_event: {key_event}")
        if key_event.event_type == EventType.KEY_PRESS:
            translate_vector = np.array([0, 0, 0], dtype=np.float32)

            if key_event.key_name == "w":
                translate_vector[2] -= self._speed_z
            elif key_event.key_name == "s":
                translate_vector[2] += self._speed_z
            elif key_event.key_name == "a":
                translate_vector[0] -= self._speed_x
            elif key_event.key_name == "d":
                translate_vector[0] += self._speed_x

            # generate translate matrix
            translate_matrix = glm.translate(translate_vector)
            # update model_matrix_numpy
            self._model_matrix_numpy = np.matmul(translate_matrix, self._model_matrix_numpy)
            # update model_matrix_buffer
            self._model_matrix_buffer.set_data(bytearray(self._model_matrix_numpy.tobytes()), 0, 1)

__init__(model_matrix_buffer: Buffer, viewport_events: ViewportEventsBase)

Initialize the ObjectControlAwsd.

Parameters:

Name Type Description Default
model_matrix_buffer gsp.types.buffer.Buffer

The buffer containing the model matrix to control.

required
viewport_events gsp_extra.viewport_events.viewport_events_base.ViewportEventsBase

The viewport events to subscribe to for keyboard input.

required
Source code in src/gsp_extra/camera_controls/object_controls_awsd.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def __init__(self, model_matrix_buffer: Buffer, viewport_events: ViewportEventsBase):
    """Initialize the ObjectControlAwsd.

    Args:
        model_matrix_buffer (Buffer): The buffer containing the model matrix to control.
        viewport_events (ViewportEventsBase): The viewport events to subscribe to for keyboard input.
    """
    # sanity checks
    assert model_matrix_buffer.get_type() == BufferType.mat4, "model_matrix must be of type mat4"
    assert model_matrix_buffer.get_count() == 1, "model_matrix must have a count of 1"

    # copy arguments
    self._model_matrix_buffer = model_matrix_buffer
    self._model_matrix_numpy = Bufferx.to_numpy(self._model_matrix_buffer)[0]
    self._viewport_events = viewport_events
    self._speed_x = 0.1
    self._speed_z = 0.1

    # Subscribe to keyboard and mouse events
    self._viewport_events.key_press_event.subscribe(self._on_key_event)
    self._viewport_events.key_release_event.subscribe(self._on_key_event)

close()

Unsubscribe from events.

Source code in src/gsp_extra/camera_controls/object_controls_awsd.py
41
42
43
44
def close(self):
    """Unsubscribe from events."""
    self._viewport_events.key_press_event.unsubscribe(self._on_key_event)
    self._viewport_events.key_release_event.unsubscribe(self._on_key_event)

Object Controls Trackball

gsp_extra.camera_controls.object_controls_trackball

Implements camera controls using AWSD keys for movement and mouse for orientation.

ObjectControlsTrackball

Implements camera controls using AWSD keys for movement and mouse for orientation.

Source code in src/gsp_extra/camera_controls/object_controls_trackball.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class ObjectControlsTrackball:
    """Implements camera controls using AWSD keys for movement and mouse for orientation."""

    def __init__(self, model_matrix_buffer: Buffer, viewport_events: ViewportEventsBase):
        """Initialize the ObjectControlsTrackball.

        Args:
            model_matrix_buffer (Buffer): The buffer containing the model matrix to control.
            viewport_events (ViewportEventsBase): The viewport events to subscribe to for mouse input.
        """
        # sanity checks
        assert model_matrix_buffer.get_type() == BufferType.mat4, "model_matrix must be of type mat4"
        assert model_matrix_buffer.get_count() == 1, "model_matrix must have a count of 1"

        # copy arguments
        self._model_matrix_buffer = model_matrix_buffer
        self._model_matrix_numpy: np.ndarray = Bufferx.to_numpy(self._model_matrix_buffer)[0]
        self._viewport_events = viewport_events
        self._button_pressed: bool = False
        self._trackball = Trackball()

        self._start_x: float = 0.0
        self._start_y: float = 0.0

        # Subscribe to keyboard and mouse events
        self._viewport_events.button_press_event.subscribe(self._on_button_press)
        self._viewport_events.button_release_event.subscribe(self._on_button_release)
        self._viewport_events.mouse_move_event.subscribe(self._on_mouse_move)

    def close(self):
        """Unsubscribe from events."""
        self._viewport_events.button_press_event.unsubscribe(self._on_button_press)
        self._viewport_events.button_release_event.unsubscribe(self._on_button_release)
        self._viewport_events.mouse_move_event.unsubscribe(self._on_mouse_move)

    def _on_button_press(self, mouse_event: MouseEvent):
        self._button_pressed = True
        self._start_x = mouse_event.x_ndc
        self._start_y = mouse_event.y_ndc

    def _on_button_release(self, mouse_event: MouseEvent):
        self._button_pressed = False

    def _on_mouse_move(self, mouse_event: MouseEvent):
        # ignore if no button is pressed
        if self._button_pressed is False:
            return

        dx = mouse_event.x_ndc - self._start_x
        dy = mouse_event.y_ndc - self._start_y
        self._trackball.drag_to(self._start_x, self._start_y, dx, dy)
        self._start_x = mouse_event.x_ndc
        self._start_y = mouse_event.y_ndc
        # update the model matrix
        np.copyto(self._model_matrix_numpy, self._trackball.model.T)
        self._model_matrix_buffer.set_data(bytearray(self._model_matrix_numpy.tobytes()), 0, 1)

__init__(model_matrix_buffer: Buffer, viewport_events: ViewportEventsBase)

Initialize the ObjectControlsTrackball.

Parameters:

Name Type Description Default
model_matrix_buffer gsp.types.buffer.Buffer

The buffer containing the model matrix to control.

required
viewport_events gsp_extra.viewport_events.viewport_events_base.ViewportEventsBase

The viewport events to subscribe to for mouse input.

required
Source code in src/gsp_extra/camera_controls/object_controls_trackball.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def __init__(self, model_matrix_buffer: Buffer, viewport_events: ViewportEventsBase):
    """Initialize the ObjectControlsTrackball.

    Args:
        model_matrix_buffer (Buffer): The buffer containing the model matrix to control.
        viewport_events (ViewportEventsBase): The viewport events to subscribe to for mouse input.
    """
    # sanity checks
    assert model_matrix_buffer.get_type() == BufferType.mat4, "model_matrix must be of type mat4"
    assert model_matrix_buffer.get_count() == 1, "model_matrix must have a count of 1"

    # copy arguments
    self._model_matrix_buffer = model_matrix_buffer
    self._model_matrix_numpy: np.ndarray = Bufferx.to_numpy(self._model_matrix_buffer)[0]
    self._viewport_events = viewport_events
    self._button_pressed: bool = False
    self._trackball = Trackball()

    self._start_x: float = 0.0
    self._start_y: float = 0.0

    # Subscribe to keyboard and mouse events
    self._viewport_events.button_press_event.subscribe(self._on_button_press)
    self._viewport_events.button_release_event.subscribe(self._on_button_release)
    self._viewport_events.mouse_move_event.subscribe(self._on_mouse_move)

close()

Unsubscribe from events.

Source code in src/gsp_extra/camera_controls/object_controls_trackball.py
44
45
46
47
48
def close(self):
    """Unsubscribe from events."""
    self._viewport_events.button_press_event.unsubscribe(self._on_button_press)
    self._viewport_events.button_release_event.unsubscribe(self._on_button_release)
    self._viewport_events.mouse_move_event.unsubscribe(self._on_mouse_move)

Miscellaneous Module

Various utility helpers and render item definitions.

Render Item

gsp_extra.misc.render_item

Render item definition.

RenderItem dataclass

Render item is a dataclasss containing all necessary information for rendering a visual in a viewport.

Source code in src/gsp_extra/misc/render_item.py
13
14
15
16
17
18
19
20
21
22
23
24
@dataclass
class RenderItem:
    """Render item is a dataclasss containing all necessary information for rendering a visual in a viewport."""

    viewport: Viewport
    """Viewport where the visual will be rendered."""
    visual_base: VisualBase
    """Visual to be rendered."""
    model_matrix: Buffer
    """Model matrix for transforming the visual."""
    camera: Camera
    """Camera used for rendering the visual."""

camera: Camera instance-attribute

Camera used for rendering the visual.

model_matrix: Buffer instance-attribute

Model matrix for transforming the visual.

viewport: Viewport instance-attribute

Viewport where the visual will be rendered.

visual_base: VisualBase instance-attribute

Visual to be rendered.

Colorama Utils

gsp_extra.misc.colorama_utils

Colorama utility functions.

text_cyan(text: str) -> str

Return the given text string colored in cyan.

Source code in src/gsp_extra/misc/colorama_utils.py
10
11
12
def text_cyan(text: str) -> str:
    """Return the given text string colored in cyan."""
    return colorama.Fore.CYAN + text + colorama.Style.RESET_ALL

text_magenta(text: str) -> str

Return the given text string colored in magenta.

Source code in src/gsp_extra/misc/colorama_utils.py
15
16
17
def text_magenta(text: str) -> str:
    """Return the given text string colored in magenta."""
    return colorama.Fore.MAGENTA + text + colorama.Style.RESET_ALL

MPL3D Module

3D mathematics utilities for graphics operations.

GLM

gsp_extra.mpl3d.glm

OpenGL Mathematics (GLM) utilities for numpy.

This module provides mathematical functions commonly used in 3D graphics, including matrix transformations, projections, rotations, and vector operations. All operations are implemented using numpy for efficient computation.

align(U: np.ndarray, V: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Return the rotation matrix that aligns vector U to vector V.

Parameters:

Name Type Description Default
U numpy.ndarray

First vector

required
V numpy.ndarray

Second vector

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Rotation matrix

Source code in src/gsp_extra/mpl3d/glm.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def align(U: np.ndarray, V: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Return the rotation matrix that aligns vector U to vector V.

    Args:
        U (np.ndarray): First vector
        V (np.ndarray): Second vector
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Rotation matrix
    """
    a, b = normalize(U), normalize(V)
    v = np.cross(a, b)
    c = np.dot(a, b)
    s = np.linalg.norm(v)
    K = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])
    R = np.zeros((4, 4), dtype=dtype)
    R[:3, :3] = np.eye(3) + K + K @ K * ((1 - c) / (s**2))
    R[3, 3] = 1

    return R

camera(xrotation: float = 25.0, yrotation: float = 45.0, zoom: float = 1.0, mode: Literal['perspective', 'ortho'] = 'perspective') -> np.ndarray

Create a camera transformation matrix.

Parameters:

Name Type Description Default
xrotation float

Rotation around the X axis in degrees.

25.0
yrotation float

Rotation around the Y axis in degrees.

45.0
zoom float

Zoom factor.

1.0
mode typing.Literal['perspective', 'ortho']

Camera mode.

'perspective'

Returns:

Type Description
numpy.ndarray

np.ndarray: Camera matrix

Raises:

Type Description
ValueError

If an unknown camera mode is provided.

Source code in src/gsp_extra/mpl3d/glm.py
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
def camera(xrotation: float = 25.0, yrotation: float = 45.0, zoom: float = 1.0, mode: Literal["perspective", "ortho"] = "perspective") -> np.ndarray:
    """Create a camera transformation matrix.

    Args:
        xrotation (float): Rotation around the X axis in degrees.
        yrotation (float): Rotation around the Y axis in degrees.
        zoom (float): Zoom factor.
        mode (Literal["perspective", "ortho"]): Camera mode.

    Returns:
        np.ndarray: Camera matrix

    Raises:
        ValueError: If an unknown camera mode is provided.
    """
    xrotation = min(max(xrotation, 0), 90)
    yrotation = min(max(yrotation, 0), 90)
    zoom = max(0.1, zoom)
    scale_vector = np.array([zoom, zoom, zoom], dtype=np.float32)
    model = scale(scale_vector) @ xrotate(xrotation) @ yrotate(yrotation)
    translate_vector = np.array([0, 0, -4.5], dtype=np.float32)
    view = translate(translate_vector)
    if mode == "ortho":
        proj = ortho(-1, +1, -1, +1, 1, 100)
    elif mode == "perspective":
        proj = perspective(25, 1, 1, 100)
    else:
        raise ValueError("Unknown camera mode: " + mode)
    return proj @ view @ model

center(vertices: np.ndarray) -> np.ndarray

Center vertices around the origin.

Parameters:

Name Type Description Default
vertices numpy.ndarray

Vertices to center

required

Returns:

Type Description
numpy.ndarray

np.ndarray: vertices centered

Source code in src/gsp_extra/mpl3d/glm.py
245
246
247
248
249
250
251
252
253
254
255
256
def center(vertices: np.ndarray) -> np.ndarray:
    """Center vertices around the origin.

    Args:
        vertices (np.ndarray): Vertices to center

    Returns:
        np.ndarray: vertices centered
    """
    vmin = vertices.min(axis=0)
    vmax = vertices.max(axis=0)
    return vertices - (vmax + vmin) / 2

clamp(V: np.ndarray, vmin: float = 0, vmax: float = 1) -> np.ndarray

Clamp values between minimum and maximum bounds.

Parameters:

Name Type Description Default
V numpy.ndarray

Array of values to clamp.

required
vmin float

Minimum value (default: 0).

0
vmax float

Maximum value (default: 1).

1

Returns:

Type Description
numpy.ndarray

Array with values clamped to [vmin, vmax].

Source code in src/gsp_extra/mpl3d/glm.py
27
28
29
30
31
32
33
34
35
36
37
38
def clamp(V: np.ndarray, vmin: float = 0, vmax: float = 1) -> np.ndarray:
    """Clamp values between minimum and maximum bounds.

    Args:
        V: Array of values to clamp.
        vmin: Minimum value (default: 0).
        vmax: Maximum value (default: 1).

    Returns:
        Array with values clamped to [vmin, vmax].
    """
    return np.minimum(np.maximum(V, vmin), vmax)

fit(vertices: np.ndarray) -> np.ndarray

Fit vertices to the normalized cube.

Parameters:

Name Type Description Default
vertices numpy.ndarray

Vertices to fit

required

Returns:

Type Description
numpy.ndarray

np.ndarray: vertices contained in the normalize cube

Source code in src/gsp_extra/mpl3d/glm.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def fit(vertices: np.ndarray) -> np.ndarray:
    """Fit vertices to the normalized cube.

    Args:
        vertices (np.ndarray): Vertices to fit

    Returns:
        np.ndarray: vertices contained in the normalize cube
    """
    Vmin = vertices.min(axis=0)
    Vmax = vertices.max(axis=0)
    # return 2*(vertices-vmin) / max(vmax-vmin)-1
    V = 2 * (vertices - Vmin) / max(Vmax - Vmin) - 1
    return V - (V.min(axis=0) + V.max(axis=0)) / 2

frontback(triangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]

Sort front and back facing triangles.

Parameters:

Name Type Description Default
triangles numpy.ndarray

Triangles to sort

required

Returns:

Type Description
tuple[numpy.ndarray, numpy.ndarray]

tuple[np.ndarray, np.ndarray]: front and back facing triangles as (n1,3) and (n2,3) arrays (n1+n2=n)

Source code in src/gsp_extra/mpl3d/glm.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def frontback(triangles: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Sort front and back facing triangles.

    Args:
        triangles (np.ndarray): Triangles to sort

    Returns:
        tuple[np.ndarray, np.ndarray]: front and back facing triangles as (n1,3) and (n2,3) arrays (n1+n2=n)
    """
    Z = (
        (triangles[:, 1, 0] - triangles[:, 0, 0]) * (triangles[:, 1, 1] + triangles[:, 0, 1])
        + (triangles[:, 2, 0] - triangles[:, 1, 0]) * (triangles[:, 2, 1] + triangles[:, 1, 1])
        + (triangles[:, 0, 0] - triangles[:, 2, 0]) * (triangles[:, 0, 1] + triangles[:, 2, 1])
    )
    return Z < 0, Z >= 0

frustum(left: float, right: float, bottom: float, top: float, znear: float, zfar: float, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a view frustum projection matrix.

Parameters:

Name Type Description Default
left float

Left coordinate of the field of view.

required
right float

Right coordinate of the field of view.

required
bottom float

Bottom coordinate of the field of view.

required
top float

Top coordinate of the field of view.

required
znear float

Near coordinate of the field of view.

required
zfar float

Far coordinate of the field of view.

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: View frustum matrix

Source code in src/gsp_extra/mpl3d/glm.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def frustum(
    left: float,
    right: float,
    bottom: float,
    top: float,
    znear: float,
    zfar: float,
    dtype: np.dtype = np.dtype(np.float32),
) -> np.ndarray:
    r"""Create a view frustum projection matrix.

    Args:
        left (float): Left coordinate of the field of view.
        right (float): Right coordinate of the field of view.
        bottom (float): Bottom coordinate of the field of view.
        top (float): Top coordinate of the field of view.
        znear (float): Near coordinate of the field of view.
        zfar (float): Far coordinate of the field of view.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: View frustum matrix
    """
    M = np.zeros((4, 4), dtype=dtype)
    M[0, 0] = +2.0 * znear / (right - left)
    M[1, 1] = +2.0 * znear / (top - bottom)
    M[2, 2] = -(zfar + znear) / (zfar - znear)
    M[0, 2] = (right + left) / (right - left)
    M[2, 1] = (top + bottom) / (top - bottom)
    M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
    M[3, 2] = -1.0

    return M

lookat(eye: tuple[float, float, float] = (0, 0, 4.5), center: tuple[float, float, float] = (0, 0, 0), up: tuple[float, float, float] = (0, 0, 1), dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a viewing matrix derived from an eye point, reference point, and up vector.

Parameters:

Name Type Description Default
eye tuple[float, float, float]

Eye point

(0, 0, 4.5)
center tuple[float, float, float]

Reference point

(0, 0, 0)
up tuple[float, float, float]

Up vector

(0, 0, 1)
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: View matrix

Source code in src/gsp_extra/mpl3d/glm.py
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
def lookat(
    eye: tuple[float, float, float] = (0, 0, 4.5),
    center: tuple[float, float, float] = (0, 0, 0),
    up: tuple[float, float, float] = (0, 0, 1),
    dtype: np.dtype = np.dtype(np.float32),
) -> np.ndarray:
    """Create a viewing matrix derived from an eye point, reference point, and up vector.

    Args:
        eye (tuple[float, float, float]): Eye point
        center (tuple[float, float, float]): Reference point
        up (tuple[float, float, float]): Up vector
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: View matrix
    """
    eye_np = np.array(eye)
    center_np = np.array(center)
    up_np = np.array(up)

    Z = normalize(eye_np - center_np)
    Y = up_np
    X = normalize(np.cross(Y, Z))
    Y = normalize(np.cross(Z, X))
    return np.array(
        [
            [X[0], X[1], X[2], -np.dot(X, eye_np)],
            [Y[0], Y[1], Y[2], -np.dot(Y, eye_np)],
            [Z[0], Z[1], Z[2], -np.dot(Z, eye_np)],
            [0, 0, 0, 1],
        ],
        dtype=dtype,
    )

normalize(V: np.ndarray) -> np.ndarray

Normalize a vector or array of vectors to unit length.

Parameters:

Name Type Description Default
V numpy.ndarray

Vector or array of vectors to normalize.

required

Returns:

Type Description
numpy.ndarray

Normalized vector(s) with unit length.

Source code in src/gsp_extra/mpl3d/glm.py
15
16
17
18
19
20
21
22
23
24
def normalize(V: np.ndarray) -> np.ndarray:
    """Normalize a vector or array of vectors to unit length.

    Args:
        V: Vector or array of vectors to normalize.

    Returns:
        Normalized vector(s) with unit length.
    """
    return V / (1e-16 + np.sqrt((np.array(V) ** 2).sum(axis=-1)))[..., np.newaxis]

ortho(left: float, right: float, bottom: float, top: float, znear: float, zfar: float, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create an orthographic projection matrix.

Parameters:

Name Type Description Default
left float

Left coordinate of the field of view.

required
right float

Right coordinate of the field of view.

required
bottom float

Bottom coordinate of the field of view.

required
top float

Top coordinate of the field of view.

required
znear float

Near coordinate of the field of view.

required
zfar float

Far coordinate of the field of view.

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Orthographic projection matrix

Source code in src/gsp_extra/mpl3d/glm.py
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
def ortho(left: float, right: float, bottom: float, top: float, znear: float, zfar: float, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create an orthographic projection matrix.

    Args:
        left (float): Left coordinate of the field of view.
        right (float): Right coordinate of the field of view.
        bottom (float): Bottom coordinate of the field of view.
        top (float): Top coordinate of the field of view.
        znear (float): Near coordinate of the field of view.
        zfar (float): Far coordinate of the field of view.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Orthographic projection matrix
    """
    M = np.zeros((4, 4), dtype=dtype)
    M[0, 0] = +2.0 / (right - left)
    M[1, 1] = +2.0 / (top - bottom)
    M[2, 2] = -2.0 / (zfar - znear)
    M[3, 3] = 1.0
    M[0, 2] = -(right + left) / float(right - left)
    M[1, 3] = -(top + bottom) / float(top - bottom)
    M[2, 3] = -(zfar + znear) / float(zfar - znear)

    return M

perspective(fovy: float, aspect: float, znear: float, zfar: float, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a perspective projection matrix.

Parameters:

Name Type Description Default
fovy float

The field of view along the y axis.

required
aspect float

Aspect ratio of the view.

required
znear float

Near coordinate of the field of view.

required
zfar float

Far coordinate of the field of view.

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Perspective projection matrix

Source code in src/gsp_extra/mpl3d/glm.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def perspective(
    fovy: float,
    aspect: float,
    znear: float,
    zfar: float,
    dtype: np.dtype = np.dtype(np.float32),
) -> np.ndarray:
    """Create a perspective projection matrix.

    Args:
        fovy (float): The field of view along the y axis.
        aspect (float): Aspect ratio of the view.
        znear (float): Near coordinate of the field of view.
        zfar (float): Far coordinate of the field of view.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Perspective projection matrix
    """
    h = np.tan(0.5 * np.radians(fovy)) * znear
    w = h * aspect
    return frustum(-w, w, -h, h, znear, zfar, dtype)

rotate(angle: float, axis: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a rotation matrix around an arbitrary axis.

Parameters:

Name Type Description Default
angle float

Specifies the angle of rotation, in degrees.

required
axis numpy.ndarray

Axis of rotation

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Rotation matrix

Source code in src/gsp_extra/mpl3d/glm.py
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
def rotate(angle: float, axis: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a rotation matrix around an arbitrary axis.

    Args:
        angle (float): Specifies the angle of rotation, in degrees.
        axis (np.ndarray): Axis of rotation
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Rotation matrix
    """
    t = np.radians(angle)

    axis = normalize(np.array(axis))
    a = np.cos(t / 2)
    b, c, d = -axis * np.sin(t / 2)
    aa, bb, cc, dd = a * a, b * b, c * c, d * d
    bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d
    R = np.array(
        [
            [aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac), 0],
            [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab), 0],
            [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc, 0],
            [0, 0, 0, 1],
        ],
        dtype=dtype,
    )

    return R

scale(scale: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a non-uniform scaling matrix along the x, y, and z axes.

Parameters:

Name Type Description Default
scale numpy.ndarray

Scaling vector

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Scaling matrix

Source code in src/gsp_extra/mpl3d/glm.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def scale(scale: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a non-uniform scaling matrix along the x, y, and z axes.

    Args:
        scale (np.ndarray): Scaling vector
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Scaling matrix
    """
    x, y, z = np.array(scale)
    S = np.array(
        [
            [x, 0, 0, 0],
            [0, y, 0, 0],
            [0, 0, z, 0],
            [0, 0, 0, 1],
        ],
        dtype=dtype,
    )

    return S

translate(translate: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a translation matrix by a given vector.

Parameters:

Name Type Description Default
translate numpy.ndarray

Translation vector.

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Translation matrix

Source code in src/gsp_extra/mpl3d/glm.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def translate(translate: np.ndarray, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a translation matrix by a given vector.

    Args:
        translate (np.ndarray): Translation vector.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Translation matrix
    """
    x, y, z = np.array(translate)
    T = np.array([[1, 0, 0, x], [0, 1, 0, y], [0, 0, 1, z], [0, 0, 0, 1]], dtype=dtype)

    return T

viewport(x: int, y: int, w: int, h: int, d: float, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a viewport transformation matrix.

Parameters:

Name Type Description Default
x int

X origin (pixels) of the viewport (lower left)

required
y int

Y origin (pixels) of the viewport (lower left)

required
w int

Width (pixels) of the viewport

required
h int

Height (pixels) of the viewport

required
d float

Depth of the viewport.

required
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Viewport matrix

Source code in src/gsp_extra/mpl3d/glm.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def viewport(x: int, y: int, w: int, h: int, d: float, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a viewport transformation matrix.

    Args:
        x (int): X origin (pixels) of the viewport (lower left)
        y (int): Y origin (pixels) of the viewport (lower left)
        w (int): Width (pixels) of the viewport
        h (int): Height (pixels) of the viewport
        d (float): Depth of the viewport.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Viewport matrix
    """
    M = np.array(
        [
            [w / 2, 0, 0, x + w / 2],
            [0, h / 2, 0, y + h / 2],
            [0, 0, d / 2, d / 2],
            [0, 0, 0, 1],
        ],
        dtype=dtype,
    )
    return M

xrotate(angle_x: float = 0.0, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a rotation matrix about the X axis.

Parameters:

Name Type Description Default
angle_x float

Specifies the angle of rotation, in degrees.

0.0
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Rotation matrix

Source code in src/gsp_extra/mpl3d/glm.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
def xrotate(angle_x: float = 0.0, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a rotation matrix about the X axis.

    Args:
        angle_x (float):
            Specifies the angle of rotation, in degrees.
        dtype (np.dtype):
            dtype of the resulting array

    Returns:
       np.ndarray: Rotation matrix
    """
    t = np.radians(angle_x)
    c, s = np.cos(t), np.sin(t)
    R = np.array([[1, 0, 0, 0], [0, c, -s, 0], [0, s, c, 0], [0, 0, 0, 1]], dtype=dtype)

    return R

yrotate(angle_y: float = 0.0, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a rotation matrix about the Y axis.

Parameters:

Name Type Description Default
angle_y float

Specifies the angle of rotation, in degrees.

0.0
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Rotation matrix

Source code in src/gsp_extra/mpl3d/glm.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def yrotate(angle_y: float = 0.0, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a rotation matrix about the Y axis.

    Args:
        angle_y (float): Specifies the angle of rotation, in degrees.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Rotation matrix
    """
    t = np.radians(angle_y)
    c, s = np.cos(t), np.sin(t)
    R = np.array([[c, 0, s, 0], [0, 1, 0, 0], [-s, 0, c, 0], [0, 0, 0, 1]], dtype=dtype)

    return R

zrotate(angle_z: float = 0.0, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray

Create a rotation matrix about the Z axis.

Parameters:

Name Type Description Default
angle_z float

Specifies the angle of rotation, in degrees.

0.0
dtype numpy.dtype

dtype of the resulting array

numpy.dtype(numpy.float32)

Returns:

Type Description
numpy.ndarray

np.ndarray: Rotation matrix

Source code in src/gsp_extra/mpl3d/glm.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def zrotate(angle_z: float = 0.0, dtype: np.dtype = np.dtype(np.float32)) -> np.ndarray:
    """Create a rotation matrix about the Z axis.

    Args:
        angle_z (float): Specifies the angle of rotation, in degrees.
        dtype (np.dtype): dtype of the resulting array

    Returns:
        np.ndarray: Rotation matrix
    """
    t = np.radians(angle_z)
    c, s = np.cos(t), np.sin(t)
    R = np.array([[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=dtype)

    return R

Trackball

gsp_extra.mpl3d.trackball

Provides a virtual trackball for 3D scene viewing.

Example usage:

trackball = Trackball(45,30)

@window.event def on_mouse_drag(x, y, dx, dy, button): trackball.drag(x,y,dx,dy)

@window.event def on_resize(width,height): glViewport(0, 0, window.width, window.height) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(45, window.width / float(window.height), .1, 1000) glMatrixMode (GL_MODELVIEW) glLoadIdentity () glTranslatef (0, 0, -3) glMultMatrixf(trackball.model)

You can also set trackball orientation directly by setting theta and phi value expressed in degrees. Theta relates to the rotation angle around X axis while phi relates to the rotation angle around Z axis.

Trackball

Bases: object

Virtual trackball for 3D scene viewing.

Source code in src/gsp_extra/mpl3d/trackball.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
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
373
374
375
376
377
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
419
420
421
class Trackball(object):
    """Virtual trackball for 3D scene viewing."""

    def __init__(self, theta: float = 0, phi: float = 0):
        """Build a new trackball with specified view.

        Args:
            theta: Initial rotation angle around the X axis in degrees.
            phi: Initial rotation angle around the Z axis in degrees.
        """
        self._rotation = [0, 0, 0, 1]
        self._count = 0
        self._model = np.zeros((4, 4), float)
        self._RENORMCOUNT = 97
        self._TRACKBALLSIZE = 0.8
        self._set_orientation(theta, phi)

    def drag_to(self, x: float, y: float, dx: float, dy: float):
        """Move trackball view from x,y to x+dx,y+dy.

        Updates the trackball rotation based on mouse drag movement.

        Args:
            x: Current x position in normalized coordinates [-1, 1].
            y: Current y position in normalized coordinates [-1, 1].
            dx: Change in x position.
            dy: Change in y position.
        """
        q = self._rotate(x, y, dx, dy)
        self._rotation = _q_add(q, self._rotation)
        self._count += 1
        if self._count > self._RENORMCOUNT:
            self._rotation = _q_normalize(self._rotation)
            self._count = 0
        self._model = _q_rotmatrix(self._rotation)

    @property
    def model(self) -> np.ndarray:
        """Model transformation (read-only).

        Returns:
            The current model transformation matrix as a 4x4 numpy array.
        """
        return self._model

    @property
    def theta(self):
        """Angle (in degrees) around the z axis."""
        self._theta, _ = self._get_orientation()
        return self._theta

    @theta.setter
    def theta(self, theta: float):
        self._set_orientation(math.fmod(theta, 360.0), math.fmod(self._phi, 360.0))

    @property
    def phi(self):
        """Angle (in degrees) around the x axis."""
        _, self._phi = self._get_orientation()
        return self._phi

    @phi.setter
    def phi(self, phi: float):
        self._set_orientation(math.fmod(self._theta, 360.0), math.fmod(phi, 360.0))

    def _get_orientation(self):
        """Return current computed orientation (theta,phi)."""
        q0, q1, q2, q3 = self._rotation
        ax = math.atan(2 * (q0 * q1 + q2 * q3) / (1 - 2 * (q1 * q1 + q2 * q2))) * 180.0 / math.pi
        az = math.atan(2 * (q0 * q3 + q1 * q2) / (1 - 2 * (q2 * q2 + q3 * q3))) * 180.0 / math.pi
        return -az, ax

    def _set_orientation(self, theta: float, phi: float):
        """Computes rotation corresponding to theta and phi."""
        self._theta = theta
        self._phi = phi
        angle = self._theta * (math.pi / 180.0)
        sine = math.sin(0.5 * angle)
        xrot = [1 * sine, 0, 0, math.cos(0.5 * angle)]
        angle = self._phi * (math.pi / 180.0)
        sine = math.sin(0.5 * angle)
        zrot = [0, 0, sine, math.cos(0.5 * angle)]
        self._rotation = _q_add(xrot, zrot)
        self._model = _q_rotmatrix(self._rotation)

    def _project(self, r: float, x: float, y: float):
        """Project an x,y pair onto a sphere of radius r or a hyperbolic sheet.

        Projects onto a hyperbolic sheet if we are away from the center of the sphere.

        Args:
            r: Sphere radius.
            x: X coordinate.
            y: Y coordinate.

        Returns:
            The z coordinate of the projection.
        """
        d = math.sqrt(x * x + y * y)
        if d < r * 0.70710678118654752440:  # Inside sphere
            z = math.sqrt(r * r - d * d)
        else:  # On hyperbola
            t = r / 1.41421356237309504880
            z = t * t / d
        return z

    def _rotate(self, x: float, y: float, dx: float, dy: float):
        """Simulate a track-ball.

        Project the points onto the virtual trackball, then figure out the
        axis of rotation, which is the cross product of x,y and x+dx,y+dy.

        Note: This is a deformed trackball-- this is a trackball in the
        center, but is deformed into a hyperbolic sheet of rotation away
        from the center.  This particular function was chosen after trying
        out several variations.

        Args:
            x: Current x position.
            y: Current y position.
            dx: Change in x position.
            dy: Change in y position.
        """
        if not dx and not dy:
            return [0.0, 0.0, 0.0, 1.0]
        last = [x, y, self._project(self._TRACKBALLSIZE, x, y)]
        new = [x + dx, y + dy, self._project(self._TRACKBALLSIZE, x + dx, y + dy)]
        a = _v_cross(new, last)
        d = _v_sub(last, new)
        t = _v_length(d) / (2.0 * self._TRACKBALLSIZE)
        if t > 1.0:
            t = 1.0
        if t < -1.0:
            t = -1.0
        phi = 2.0 * math.asin(t)
        return _q_from_axis_angle(a, phi)

model: np.ndarray property

Model transformation (read-only).

Returns:

Type Description
numpy.ndarray

The current model transformation matrix as a 4x4 numpy array.

phi property writable

Angle (in degrees) around the x axis.

theta property writable

Angle (in degrees) around the z axis.

__init__(theta: float = 0, phi: float = 0)

Build a new trackball with specified view.

Parameters:

Name Type Description Default
theta float

Initial rotation angle around the X axis in degrees.

0
phi float

Initial rotation angle around the Z axis in degrees.

0
Source code in src/gsp_extra/mpl3d/trackball.py
289
290
291
292
293
294
295
296
297
298
299
300
301
def __init__(self, theta: float = 0, phi: float = 0):
    """Build a new trackball with specified view.

    Args:
        theta: Initial rotation angle around the X axis in degrees.
        phi: Initial rotation angle around the Z axis in degrees.
    """
    self._rotation = [0, 0, 0, 1]
    self._count = 0
    self._model = np.zeros((4, 4), float)
    self._RENORMCOUNT = 97
    self._TRACKBALLSIZE = 0.8
    self._set_orientation(theta, phi)

drag_to(x: float, y: float, dx: float, dy: float)

Move trackball view from x,y to x+dx,y+dy.

Updates the trackball rotation based on mouse drag movement.

Parameters:

Name Type Description Default
x float

Current x position in normalized coordinates [-1, 1].

required
y float

Current y position in normalized coordinates [-1, 1].

required
dx float

Change in x position.

required
dy float

Change in y position.

required
Source code in src/gsp_extra/mpl3d/trackball.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def drag_to(self, x: float, y: float, dx: float, dy: float):
    """Move trackball view from x,y to x+dx,y+dy.

    Updates the trackball rotation based on mouse drag movement.

    Args:
        x: Current x position in normalized coordinates [-1, 1].
        y: Current y position in normalized coordinates [-1, 1].
        dx: Change in x position.
        dy: Change in y position.
    """
    q = self._rotate(x, y, dx, dy)
    self._rotation = _q_add(q, self._rotation)
    self._count += 1
    if self._count > self._RENORMCOUNT:
        self._rotation = _q_normalize(self._rotation)
        self._count = 0
    self._model = _q_rotmatrix(self._rotation)

Transform link utilities for loading and network operations.

"TransformLink that loads data from a URI into a Buffer.

Transform Load

gsp_extra.transform_links.transform_load

TransformLink that loads data from a URI into a Buffer.

TransformLoad

Bases: gsp.transforms.transform_link_base.TransformLinkBase

Load data from a URI into a Buffer. previous buffer is ignored.

Source code in src/gsp_extra/transform_links/transform_load.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
126
127
128
129
130
class TransformLoad(TransformLinkBase):
    """Load data from a URI into a Buffer. previous buffer is ignored."""

    __slots__ = ["_uri", "_buffer_type"]

    def __init__(self, uri: str, buffer_type: BufferType) -> None:
        """Initialize the TransformLoad.

        Args:
            uri (str): The URI to load data from.
            buffer_type (BufferType): The type of buffer to create.
        """
        self._uri = uri
        self._buffer_type = buffer_type

    def apply(self, buffer_src: Buffer | None) -> Buffer:
        """Load data from the URI into a new Buffer.

        Args:
            buffer_src (Buffer | None): Ignored.

        Returns:
            Buffer: The loaded buffer.
        """
        item_size = BufferType.get_item_size(self._buffer_type)

        is_image = os.path.splitext(self._uri)[1].lower() in [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
        is_npy = os.path.splitext(self._uri)[1].lower() in [".npy"]
        if is_npy:
            # If the URI points to a .npy file, use numpy to load it

            # 1. Create a session object
            requests_session = requests.Session()
            # 2. Mount the FileAdapter for the 'file://' scheme
            requests_session.mount("file://", requests_file.FileAdapter())

            # Load numpy array
            response = requests_session.get(self._uri)

            response.raise_for_status()
            array = np.load(BytesIO(response.content))

            # sanity check
            assert array.nbytes % item_size == 0, f"Numpy array data size {array.nbytes} is not aligned with buffer type item size {item_size}"

            # Build a new buffer
            count = array.nbytes // item_size
            new_buffer = Buffer(count, self._buffer_type)
            new_buffer.set_data(bytearray(array.tobytes()), 0, count)
            return new_buffer
        elif is_image:
            # If the URI points to an image, use imageio to load it

            # Load image data
            image_data = imageio.v3.imread(self._uri)

            # sanity check
            assert image_data.nbytes % item_size == 0, f"Image data size {image_data.nbytes} is not aligned with buffer type item size {item_size}"

            # Build a new buffer
            count = image_data.nbytes // item_size
            new_buffer = Buffer(count, self._buffer_type)
            new_buffer.set_data(bytearray(image_data.tobytes()), 0, count)
            return new_buffer
        else:
            # Load data from URI
            response = requests.get(self._uri)
            response.raise_for_status()
            content = response.content

            # sanity check
            assert len(content) % item_size == 0, f"Data size {len(content)} is not a multiple of item size {item_size} for buffer type {self._buffer_type}"

            count = len(content) // item_size
            new_buffer = Buffer(count, self._buffer_type)
            new_buffer.set_data(bytearray(content), 0, count)
            return new_buffer

    # =============================================================================
    # Serialization functions
    # =============================================================================

    def serialize(self) -> dict[str, Any]:
        """Serialize the TransformLoad to a dictionary.

        Returns:
            dict[str, Any]: The serialized TransformLoad.
        """
        return {
            "link_type": "TransformLoad",
            "link_data": {
                "uri": self._uri,
                "buffer_type": self._buffer_type.name,
            },
        }

    @staticmethod
    def deserialize(data: dict[str, Any]) -> "TransformLoad":
        """Deserialize a TransformLoad from a dictionary.

        Args:
            data (dict[str, Any]): The serialized TransformLoad.

        Returns:
            TransformLoad: The deserialized TransformLoad instance.
        """
        assert data["link_type"] == "TransformLoad", "Invalid type for TransformLoad deserialization"
        uri: str = data["link_data"]["uri"]
        buffer_type_str: str = data["link_data"]["buffer_type"]
        buffer_type = BufferType[buffer_type_str]
        return TransformLoad(uri, buffer_type)

__init__(uri: str, buffer_type: BufferType) -> None

Initialize the TransformLoad.

Parameters:

Name Type Description Default
uri str

The URI to load data from.

required
buffer_type gsp.types.buffer_type.BufferType

The type of buffer to create.

required
Source code in src/gsp_extra/transform_links/transform_load.py
25
26
27
28
29
30
31
32
33
def __init__(self, uri: str, buffer_type: BufferType) -> None:
    """Initialize the TransformLoad.

    Args:
        uri (str): The URI to load data from.
        buffer_type (BufferType): The type of buffer to create.
    """
    self._uri = uri
    self._buffer_type = buffer_type

apply(buffer_src: Buffer | None) -> Buffer

Load data from the URI into a new Buffer.

Parameters:

Name Type Description Default
buffer_src gsp.types.buffer.Buffer | None

Ignored.

required

Returns:

Name Type Description
Buffer gsp.types.buffer.Buffer

The loaded buffer.

Source code in src/gsp_extra/transform_links/transform_load.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def apply(self, buffer_src: Buffer | None) -> Buffer:
    """Load data from the URI into a new Buffer.

    Args:
        buffer_src (Buffer | None): Ignored.

    Returns:
        Buffer: The loaded buffer.
    """
    item_size = BufferType.get_item_size(self._buffer_type)

    is_image = os.path.splitext(self._uri)[1].lower() in [".png", ".jpg", ".jpeg", ".bmp", ".tiff"]
    is_npy = os.path.splitext(self._uri)[1].lower() in [".npy"]
    if is_npy:
        # If the URI points to a .npy file, use numpy to load it

        # 1. Create a session object
        requests_session = requests.Session()
        # 2. Mount the FileAdapter for the 'file://' scheme
        requests_session.mount("file://", requests_file.FileAdapter())

        # Load numpy array
        response = requests_session.get(self._uri)

        response.raise_for_status()
        array = np.load(BytesIO(response.content))

        # sanity check
        assert array.nbytes % item_size == 0, f"Numpy array data size {array.nbytes} is not aligned with buffer type item size {item_size}"

        # Build a new buffer
        count = array.nbytes // item_size
        new_buffer = Buffer(count, self._buffer_type)
        new_buffer.set_data(bytearray(array.tobytes()), 0, count)
        return new_buffer
    elif is_image:
        # If the URI points to an image, use imageio to load it

        # Load image data
        image_data = imageio.v3.imread(self._uri)

        # sanity check
        assert image_data.nbytes % item_size == 0, f"Image data size {image_data.nbytes} is not aligned with buffer type item size {item_size}"

        # Build a new buffer
        count = image_data.nbytes // item_size
        new_buffer = Buffer(count, self._buffer_type)
        new_buffer.set_data(bytearray(image_data.tobytes()), 0, count)
        return new_buffer
    else:
        # Load data from URI
        response = requests.get(self._uri)
        response.raise_for_status()
        content = response.content

        # sanity check
        assert len(content) % item_size == 0, f"Data size {len(content)} is not a multiple of item size {item_size} for buffer type {self._buffer_type}"

        count = len(content) // item_size
        new_buffer = Buffer(count, self._buffer_type)
        new_buffer.set_data(bytearray(content), 0, count)
        return new_buffer

deserialize(data: dict[str, Any]) -> TransformLoad staticmethod

Deserialize a TransformLoad from a dictionary.

Parameters:

Name Type Description Default
data dict[str, typing.Any]

The serialized TransformLoad.

required

Returns:

Name Type Description
TransformLoad gsp_extra.transform_links.transform_load.TransformLoad

The deserialized TransformLoad instance.

Source code in src/gsp_extra/transform_links/transform_load.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
@staticmethod
def deserialize(data: dict[str, Any]) -> "TransformLoad":
    """Deserialize a TransformLoad from a dictionary.

    Args:
        data (dict[str, Any]): The serialized TransformLoad.

    Returns:
        TransformLoad: The deserialized TransformLoad instance.
    """
    assert data["link_type"] == "TransformLoad", "Invalid type for TransformLoad deserialization"
    uri: str = data["link_data"]["uri"]
    buffer_type_str: str = data["link_data"]["buffer_type"]
    buffer_type = BufferType[buffer_type_str]
    return TransformLoad(uri, buffer_type)

serialize() -> dict[str, Any]

Serialize the TransformLoad to a dictionary.

Returns:

Type Description
dict[str, typing.Any]

dict[str, Any]: The serialized TransformLoad.

Source code in src/gsp_extra/transform_links/transform_load.py
102
103
104
105
106
107
108
109
110
111
112
113
114
def serialize(self) -> dict[str, Any]:
    """Serialize the TransformLoad to a dictionary.

    Returns:
        dict[str, Any]: The serialized TransformLoad.
    """
    return {
        "link_type": "TransformLoad",
        "link_data": {
            "uri": self._uri,
            "buffer_type": self._buffer_type.name,
        },
    }

Transform Network Server

gsp_extra.transform_links.transform_network_server

Starts a network server with user-space transforms registered.

Viewport Events Module

Viewport event handling for user interactions.

Viewport Events Base

gsp_extra.viewport_events.viewport_events_base

Base class for viewport event handlers.

ViewportEventsBase

Bases: abc.ABC

Base class for window event handlers for camera controls.

Source code in src/gsp_extra/viewport_events/viewport_events_base.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class ViewportEventsBase(ABC):
    """Base class for window event handlers for camera controls."""

    __slots__ = [
        "key_press_event",
        "key_release_event",
        "button_press_event",
        "button_release_event",
        "mouse_move_event",
        "mouse_scroll_event",
        "canvas_resize_event",
    ]

    key_press_event: Event[KeyboardEventCallback]
    """Event triggered on key press"""
    key_release_event: Event[KeyboardEventCallback]
    """Event triggered on key release"""
    button_press_event: Event[MouseEventCallback]
    """Event triggered on mouse button press"""
    button_release_event: Event[MouseEventCallback]
    """Event triggered on mouse button release"""
    mouse_move_event: Event[MouseEventCallback]
    """Event triggered on mouse move"""
    mouse_scroll_event: Event[MouseEventCallback]
    """Event triggered on mouse scroll"""
    mouse_scroll_event: Event[MouseEventCallback]
    """Event triggered on mouse scroll"""
    canvas_resize_event: Event[CanvasResizeEventCallback]
    """Event triggered on canvas resize"""

button_press_event: Event[MouseEventCallback] instance-attribute

Event triggered on mouse button press

button_release_event: Event[MouseEventCallback] instance-attribute

Event triggered on mouse button release

canvas_resize_event: Event[CanvasResizeEventCallback] instance-attribute

Event triggered on canvas resize

key_press_event: Event[KeyboardEventCallback] instance-attribute

Event triggered on key press

key_release_event: Event[KeyboardEventCallback] instance-attribute

Event triggered on key release

mouse_move_event: Event[MouseEventCallback] instance-attribute

Event triggered on mouse move

mouse_scroll_event: Event[MouseEventCallback] instance-attribute

Event triggered on mouse scroll

Viewport Events Matplotlib

gsp_extra.viewport_events.viewport_events_matplotlib

MatplotlibRenderer event handler for viewport events.

ViewportEventsMatplotlib

Bases: gsp_extra.viewport_events.viewport_events_base.ViewportEventsBase

MatplotlibRenderer event handler for viewport.

Source code in src/gsp_extra/viewport_events/viewport_events_matplotlib.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
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
221
222
223
224
225
226
class ViewportEventsMatplotlib(ViewportEventsBase):
    """MatplotlibRenderer event handler for viewport."""

    __slots__ = [
        "_renderer",
        "_viewport",
        "_has_key_focus",
        "_mpl_key_press_cid",
        "_mpl_key_release_cid",
        "_mpl_button_press_cid",
        "_mpl_button_release_cid",
        "_mpl_mouse_move_cid",
        "_mpl_scroll_event_cid",
        "_mpl_resize_event_cid",
    ]

    def __init__(self, renderer: MatplotlibRenderer, viewport: Viewport) -> None:
        """Initialize the Matplotlib viewport event handler.

        Args:
            renderer: MatplotlibRenderer associated with this event handler.
            viewport: Viewport associated with this event handler.
        """
        self._renderer = renderer
        """MatplotlibRenderer associated with this event handler"""
        self._viewport = viewport
        """viewport associated with this event handler"""
        self._has_key_focus = False
        """True if this viewport has the keyboard focus"""

        # Intanciate events
        self.key_press_event = Event[KeyboardEventCallback]()
        self.key_release_event = Event[KeyboardEventCallback]()
        self.button_press_event = Event[MouseEventCallback]()
        self.button_release_event = Event[MouseEventCallback]()
        self.mouse_move_event = Event[MouseEventCallback]()
        self.mouse_scroll_event = Event[MouseEventCallback]()
        self.canvas_resize_event = Event[CanvasResizeEventCallback]()

        # event connections
        mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
        self._mpl_key_press_cid = mpl_canvas.mpl_connect("key_press_event", typing.cast(Any, self._on_key_press))
        self._mpl_key_release_cid = mpl_canvas.mpl_connect("key_release_event", typing.cast(Any, self._on_key_release))
        self._mpl_button_press_cid = mpl_canvas.mpl_connect("button_press_event", typing.cast(Any, self._on_button_press))
        self._mpl_button_release_cid = mpl_canvas.mpl_connect("button_release_event", typing.cast(Any, self._on_button_release))
        self._mpl_mouse_move_cid = mpl_canvas.mpl_connect("motion_notify_event", typing.cast(Any, self._on_mouse_move))
        self._mpl_scroll_event_cid = mpl_canvas.mpl_connect("scroll_event", typing.cast(Any, self._on_mouse_scroll))
        self._mpl_resize_event_cid = mpl_canvas.mpl_connect("resize_event", typing.cast(Any, self._on_canvas_resize))

    def close(self):
        """Close the event handler and release resources."""
        mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
        if self._mpl_key_press_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_key_press_cid)
            self._mpl_key_press_cid = None
        if self._mpl_key_release_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_key_release_cid)
            self._mpl_key_release_cid = None
        if self._mpl_button_press_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_button_press_cid)
            self._mpl_button_press_cid = None
        if self._mpl_button_release_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_button_release_cid)
            self._mpl_button_release_cid = None
        if self._mpl_mouse_move_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_mouse_move_cid)
            self._mpl_mouse_move_cid = None
        if self._mpl_scroll_event_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_scroll_event_cid)
            self._mpl_scroll_event_cid = None
        if self._mpl_resize_event_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_resize_event_cid)
            self._mpl_resize_event_cid = None

    # =============================================================================
    # Matplotlib event handler
    # =============================================================================

    def _on_key_press(self, mpl_key_event: matplotlib.backend_bases.KeyEvent) -> None:
        # discard events outside the viewport
        if self._has_key_focus is False:
            return
        # convert and dispatch event
        keyboard_event = self._mpl_key_event_to_gsp(mpl_key_event, EventType.KEY_PRESS)
        self.key_press_event.dispatch(keyboard_event)

    def _on_key_release(self, mpl_key_event: matplotlib.backend_bases.KeyEvent) -> None:
        # discard events outside the viewport
        if self._has_key_focus is False:
            return
        # convert and dispatch event
        keyboard_event = self._mpl_key_event_to_gsp(mpl_key_event, EventType.KEY_RELEASE)
        self.key_release_event.dispatch(keyboard_event)

    def _on_button_press(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # print("matplotlib button press event:", mpl_mouse_event)
        # Set key focus if the event is inside the viewport, otherwise remove key focus
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event):
            self._has_key_focus = True
        else:
            self._has_key_focus = False

        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return

        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.BUTTON_PRESS)
        self.button_press_event.dispatch(mouse_event)

    def _on_button_release(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # print("matplotlib button release event:", mpl_mouse_event)

        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return
        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.BUTTON_RELEASE)
        self.button_release_event.dispatch(mouse_event)

    def _on_mouse_move(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return
        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.MOUSE_MOVE)
        self.mouse_move_event.dispatch(mouse_event)

    def _on_mouse_scroll(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return
        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.MOUSE_SCROLL)
        self.mouse_scroll_event.dispatch(mouse_event)

    def _on_canvas_resize(self, mpl_resize_event: matplotlib.backend_bases.ResizeEvent) -> None:
        # dispatch canvas resize event
        canvas_resize_event = CanvasResizeEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=EventType.CANVAS_RESIZE,
            canvas_width_px=mpl_resize_event.width,
            canvas_height_px=mpl_resize_event.height,
        )
        self.canvas_resize_event.dispatch(canvas_resize_event)

    # =============================================================================
    #
    # =============================================================================

    def _viewport_contains_mpl_mouse_event(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> bool:
        """Check if the matplotlib mouse event is inside this viewport.

        Args:
            mpl_mouse_event: Matplotlib mouse event.

        Returns:
            True if the mouse event is inside this viewport, False otherwise.
        """
        mouse_x = mpl_mouse_event.x / UnitUtils.device_pixel_ratio()
        mouse_y = mpl_mouse_event.y / UnitUtils.device_pixel_ratio()
        if mouse_x < self._viewport.get_x():
            return False
        if mouse_x >= self._viewport.get_x() + self._viewport.get_width():
            return False
        if mouse_y < self._viewport.get_y():
            return False
        if mouse_y >= self._viewport.get_y() + self._viewport.get_height():
            return False
        return True

    # =============================================================================
    # Conversion matplotlib event to gsp events
    # =============================================================================
    def _mpl_mouse_event_to_gsp(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent, event_type: EventType) -> MouseEvent:
        # Sanity check
        assert self._viewport_contains_mpl_mouse_event(mpl_mouse_event), "Mouse event is outside the viewport"

        mouse_x = mpl_mouse_event.x / UnitUtils.device_pixel_ratio()
        mouse_y = mpl_mouse_event.y / UnitUtils.device_pixel_ratio()
        event_x: float = ((mouse_x - self._viewport.get_x()) / self._viewport.get_width() - 0.5) * 2.0
        event_y: float = ((mouse_y - self._viewport.get_y()) / self._viewport.get_height() - 0.5) * 2.0

        # print(f"Converted mouse_x: {mouse_x}, mouse_y: {mouse_y} to event_x: {event_x}, event_y: {event_y}")

        mouse_event = MouseEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=event_type,
            x_ndc=event_x,
            y_ndc=event_y,
            left_button=mpl_mouse_event.button == 1,
            middle_button=mpl_mouse_event.button == 2,
            right_button=mpl_mouse_event.button == 3,
            scroll_steps=mpl_mouse_event.step if hasattr(mpl_mouse_event, "step") else 0.0,
        )
        return mouse_event

    def _mpl_key_event_to_gsp(self, mpl_key_event: matplotlib.backend_bases.KeyEvent, event_type: EventType) -> KeyEvent:
        assert mpl_key_event.key is not None
        keyboard_event = KeyEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=event_type,
            key_name=mpl_key_event.key,
        )
        return keyboard_event

__init__(renderer: MatplotlibRenderer, viewport: Viewport) -> None

Initialize the Matplotlib viewport event handler.

Parameters:

Name Type Description Default
renderer gsp_matplotlib.renderer.MatplotlibRenderer

MatplotlibRenderer associated with this event handler.

required
viewport gsp.core.Viewport

Viewport associated with this event handler.

required
Source code in src/gsp_extra/viewport_events/viewport_events_matplotlib.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def __init__(self, renderer: MatplotlibRenderer, viewport: Viewport) -> None:
    """Initialize the Matplotlib viewport event handler.

    Args:
        renderer: MatplotlibRenderer associated with this event handler.
        viewport: Viewport associated with this event handler.
    """
    self._renderer = renderer
    """MatplotlibRenderer associated with this event handler"""
    self._viewport = viewport
    """viewport associated with this event handler"""
    self._has_key_focus = False
    """True if this viewport has the keyboard focus"""

    # Intanciate events
    self.key_press_event = Event[KeyboardEventCallback]()
    self.key_release_event = Event[KeyboardEventCallback]()
    self.button_press_event = Event[MouseEventCallback]()
    self.button_release_event = Event[MouseEventCallback]()
    self.mouse_move_event = Event[MouseEventCallback]()
    self.mouse_scroll_event = Event[MouseEventCallback]()
    self.canvas_resize_event = Event[CanvasResizeEventCallback]()

    # event connections
    mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
    self._mpl_key_press_cid = mpl_canvas.mpl_connect("key_press_event", typing.cast(Any, self._on_key_press))
    self._mpl_key_release_cid = mpl_canvas.mpl_connect("key_release_event", typing.cast(Any, self._on_key_release))
    self._mpl_button_press_cid = mpl_canvas.mpl_connect("button_press_event", typing.cast(Any, self._on_button_press))
    self._mpl_button_release_cid = mpl_canvas.mpl_connect("button_release_event", typing.cast(Any, self._on_button_release))
    self._mpl_mouse_move_cid = mpl_canvas.mpl_connect("motion_notify_event", typing.cast(Any, self._on_mouse_move))
    self._mpl_scroll_event_cid = mpl_canvas.mpl_connect("scroll_event", typing.cast(Any, self._on_mouse_scroll))
    self._mpl_resize_event_cid = mpl_canvas.mpl_connect("resize_event", typing.cast(Any, self._on_canvas_resize))

close()

Close the event handler and release resources.

Source code in src/gsp_extra/viewport_events/viewport_events_matplotlib.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def close(self):
    """Close the event handler and release resources."""
    mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
    if self._mpl_key_press_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_key_press_cid)
        self._mpl_key_press_cid = None
    if self._mpl_key_release_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_key_release_cid)
        self._mpl_key_release_cid = None
    if self._mpl_button_press_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_button_press_cid)
        self._mpl_button_press_cid = None
    if self._mpl_button_release_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_button_release_cid)
        self._mpl_button_release_cid = None
    if self._mpl_mouse_move_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_mouse_move_cid)
        self._mpl_mouse_move_cid = None
    if self._mpl_scroll_event_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_scroll_event_cid)
        self._mpl_scroll_event_cid = None
    if self._mpl_resize_event_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_resize_event_cid)
        self._mpl_resize_event_cid = None

Viewport Events Datoviz

gsp_extra.viewport_events.viewport_events_datoviz

DatovizRenderer event handler for viewport events.

ViewportEventsDatoviz

Bases: gsp_extra.viewport_events.viewport_events_base.ViewportEventsBase

DatovizRenderer event handler for viewport.

Source code in src/gsp_extra/viewport_events/viewport_events_datoviz.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
class ViewportEventsDatoviz(ViewportEventsBase):
    """DatovizRenderer event handler for viewport."""

    __slots__ = ("_renderer", "_viewport", "_has_key_focus", "_is_closed")

    def __init__(self, renderer: DatovizRenderer, viewport: Viewport) -> None:
        """Initialize the Datoviz viewport event handler."""
        self._renderer = renderer
        """MatplotlibRenderer associated with this event handler"""
        self._viewport = viewport
        """viewport associated with this event handler"""
        self._has_key_focus = False
        """True if this viewport has the keyboard focus"""
        self._is_closed = False
        """True if the event handler is closed"""

        # Intanciate events
        self.key_press_event = Event[KeyboardEventCallback]()
        self.key_release_event = Event[KeyboardEventCallback]()
        self.button_press_event = Event[MouseEventCallback]()
        self.button_release_event = Event[MouseEventCallback]()
        self.mouse_move_event = Event[MouseEventCallback]()
        self.mouse_scroll_event = Event[MouseEventCallback]()
        self.canvas_resize_event = Event[CanvasResizeEventCallback]()

        dvz_app: dvz.App = self._renderer.get_dvz_app()
        dvz_figure: _DvzFigure = self._renderer.get_dvz_figure()

        # =============================================================================
        # Connect keyboard in dvz_app
        # =============================================================================
        @dvz_app.connect(dvz_figure)
        def on_keyboard(dvz_keyboard_event: dvz.KeyboardEvent):
            # if this viewport is closed, ignore events (datoviz doesnt allow to disconnect events)
            if self._is_closed:
                return

            # Read dvz_keyboard_event properties
            dvz_event_name = dvz_keyboard_event.key_event()
            dvz_key_name = dvz_keyboard_event.key_name()

            # Convert fields to our MouseEvent
            if dvz_event_name == "press":
                event_type = EventType.KEY_PRESS
            elif dvz_event_name == "release":
                event_type = EventType.KEY_RELEASE
            else:
                return  # Unknown event

            key_name = dvz_key_name

            # Create our KeyEvent
            key_event = KeyEvent(
                viewport_uuid=self._viewport.get_uuid(),
                event_type=event_type,
                key_name=key_name,
            )

            # dispatch key_event to the proper handler
            if key_event.event_type == EventType.KEY_PRESS:
                self.key_press_event.dispatch(key_event)
            elif key_event.event_type == EventType.KEY_RELEASE:
                self.key_release_event.dispatch(key_event)
            else:
                raise ValueError(f"Unknown key event type: {key_event.event_type}")

        # =============================================================================
        # Connect mouse in dvz_app
        # =============================================================================
        @dvz_app.connect(dvz_figure)
        def on_mouse(dvz_mouse_event: dvz.MouseEvent):
            # if this viewport is closed, ignore events (datoviz doesnt allow to disconnect events)
            if self._is_closed:
                return

            # Set key focus to true if there is a mouse press is inside the viewport, otherwise remove key focus if mouse press is outside
            if dvz_mouse_event.mouse_event() == "press":
                if self._viewport_contains_dvz_mouse_event(dvz_mouse_event):
                    self._has_key_focus = True
                else:
                    self._has_key_focus = False

            # discard events outside the viewport
            if self._viewport_contains_dvz_mouse_event(dvz_mouse_event) is False:
                return

            # Read dvz_mouse_event properties
            dvz_event_name: str = dvz_mouse_event.mouse_event()
            dvz_mouse_pos: tuple[float, float] = dvz_mouse_event.pos()
            dvz_mouse_x_px: float = dvz_mouse_pos[0]
            dvz_mouse_y_px: float = self._renderer.get_canvas().get_height() - dvz_mouse_pos[1]
            dvz_button_name: str = dvz_mouse_event.button_name()
            dvz_wheel: float | None = dvz_mouse_event.wheel()

            # Convert fields to our MouseEvent
            if dvz_event_name == "press":
                event_type = EventType.BUTTON_PRESS
            elif dvz_event_name == "release":
                event_type = EventType.BUTTON_RELEASE
            elif dvz_event_name == "move":
                event_type = EventType.MOUSE_MOVE
            elif dvz_event_name == "wheel":
                event_type = EventType.MOUSE_SCROLL
            else:
                print(f'"Unknown dvz mouse event name: {dvz_event_name}"')
                return  # Unknown event

            event_x: float = (dvz_mouse_x_px - self._viewport.get_x()) / self._viewport.get_width() * 2.0 - 1.0
            event_y: float = (dvz_mouse_y_px - self._viewport.get_y()) / self._viewport.get_height() * 2.0 - 1.0

            # print(f"event_x: {event_x}, event_y: {event_y}")
            # print(
            #     f"dvz_mouse_x_px: {dvz_mouse_x_px}, dvz_mouse_y_px: {dvz_mouse_y_px}viewport x:{self._viewport.get_x()}, y:{self._viewport.get_y()}, w:{self._viewport.get_width()}, h:{self._viewport.get_height()}"
            # )

            left_button: bool = dvz_button_name == "left"
            middle_button: bool = dvz_button_name == "middle"
            right_button: bool = dvz_button_name == "right"

            event_scroll_steps: float = dvz_wheel if dvz_wheel is not None else 0.0

            # Create our MouseEvent
            mouse_event = MouseEvent(
                viewport_uuid=self._viewport.get_uuid(),
                event_type=event_type,
                x_ndc=event_x,
                y_ndc=event_y,
                left_button=left_button,
                middle_button=middle_button,
                right_button=right_button,
                scroll_steps=event_scroll_steps,
            )

            # dispatch mouse_event to the proper handler
            if mouse_event.event_type == EventType.BUTTON_PRESS:
                self.button_press_event.dispatch(mouse_event)
            elif mouse_event.event_type == EventType.BUTTON_RELEASE:
                self.button_release_event.dispatch(mouse_event)
            elif mouse_event.event_type == EventType.MOUSE_MOVE:
                self.mouse_move_event.dispatch(mouse_event)
            elif mouse_event.event_type == EventType.MOUSE_SCROLL:
                self.mouse_scroll_event.dispatch(mouse_event)
            else:
                raise ValueError(f"Unknown mouse event type: {mouse_event.event_type}")

        # =============================================================================
        # Connect resize in dvz_app
        # =============================================================================
        @dvz_app.connect(dvz_figure)
        def on_resize(dvz_resize_event: dvz.WindowEvent):
            canvas_width_px = dvz_resize_event.screen_width()  # TODO may be a good idea to rename .screen_width() to .canvas_width() or similar in datoviz
            canvas_height_px = dvz_resize_event.screen_height()
            # dispatch canvas resize event
            canvas_resize_event = CanvasResizeEvent(
                viewport_uuid=self._viewport.get_uuid(),
                event_type=EventType.CANVAS_RESIZE,
                canvas_width_px=canvas_width_px,
                canvas_height_px=canvas_height_px,
            )
            self.canvas_resize_event.dispatch(canvas_resize_event)

    def close(self):
        """Close the event handler and release resources."""
        # no more dispatch events (datoviz doesnt allow to disconnect events)
        self._is_closed = True

    # =============================================================================
    #
    # =============================================================================

    def _viewport_contains_dvz_mouse_event(self, dvz_mouse_event: dvz.MouseEvent) -> bool:
        """Check if the matplotlib mouse event is inside this viewport.

        Args:
            dvz_mouse_event: Datoviz mouse event.

        Returns:
            True if the mouse event is inside this viewport, False otherwise.
        """
        dvz_mouse_pos = dvz_mouse_event.pos()
        dvz_mouse_x = dvz_mouse_pos[0]
        dvz_mouse_y = self._renderer.get_canvas().get_height() - dvz_mouse_pos[1]

        # print(f"dvz_mouse_x: {dvz_mouse_x}, dvz_mouse_y: {dvz_mouse_y}")

        mouse_x = dvz_mouse_x
        mouse_y = dvz_mouse_y
        if mouse_x < self._viewport.get_x():
            return False
        if mouse_x >= self._viewport.get_x() + self._viewport.get_width():
            return False
        if mouse_y < self._viewport.get_y():
            return False
        if mouse_y >= self._viewport.get_y() + self._viewport.get_height():
            return False
        return True

__init__(renderer: DatovizRenderer, viewport: Viewport) -> None

Initialize the Datoviz viewport event handler.

Source code in src/gsp_extra/viewport_events/viewport_events_datoviz.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
def __init__(self, renderer: DatovizRenderer, viewport: Viewport) -> None:
    """Initialize the Datoviz viewport event handler."""
    self._renderer = renderer
    """MatplotlibRenderer associated with this event handler"""
    self._viewport = viewport
    """viewport associated with this event handler"""
    self._has_key_focus = False
    """True if this viewport has the keyboard focus"""
    self._is_closed = False
    """True if the event handler is closed"""

    # Intanciate events
    self.key_press_event = Event[KeyboardEventCallback]()
    self.key_release_event = Event[KeyboardEventCallback]()
    self.button_press_event = Event[MouseEventCallback]()
    self.button_release_event = Event[MouseEventCallback]()
    self.mouse_move_event = Event[MouseEventCallback]()
    self.mouse_scroll_event = Event[MouseEventCallback]()
    self.canvas_resize_event = Event[CanvasResizeEventCallback]()

    dvz_app: dvz.App = self._renderer.get_dvz_app()
    dvz_figure: _DvzFigure = self._renderer.get_dvz_figure()

    # =============================================================================
    # Connect keyboard in dvz_app
    # =============================================================================
    @dvz_app.connect(dvz_figure)
    def on_keyboard(dvz_keyboard_event: dvz.KeyboardEvent):
        # if this viewport is closed, ignore events (datoviz doesnt allow to disconnect events)
        if self._is_closed:
            return

        # Read dvz_keyboard_event properties
        dvz_event_name = dvz_keyboard_event.key_event()
        dvz_key_name = dvz_keyboard_event.key_name()

        # Convert fields to our MouseEvent
        if dvz_event_name == "press":
            event_type = EventType.KEY_PRESS
        elif dvz_event_name == "release":
            event_type = EventType.KEY_RELEASE
        else:
            return  # Unknown event

        key_name = dvz_key_name

        # Create our KeyEvent
        key_event = KeyEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=event_type,
            key_name=key_name,
        )

        # dispatch key_event to the proper handler
        if key_event.event_type == EventType.KEY_PRESS:
            self.key_press_event.dispatch(key_event)
        elif key_event.event_type == EventType.KEY_RELEASE:
            self.key_release_event.dispatch(key_event)
        else:
            raise ValueError(f"Unknown key event type: {key_event.event_type}")

    # =============================================================================
    # Connect mouse in dvz_app
    # =============================================================================
    @dvz_app.connect(dvz_figure)
    def on_mouse(dvz_mouse_event: dvz.MouseEvent):
        # if this viewport is closed, ignore events (datoviz doesnt allow to disconnect events)
        if self._is_closed:
            return

        # Set key focus to true if there is a mouse press is inside the viewport, otherwise remove key focus if mouse press is outside
        if dvz_mouse_event.mouse_event() == "press":
            if self._viewport_contains_dvz_mouse_event(dvz_mouse_event):
                self._has_key_focus = True
            else:
                self._has_key_focus = False

        # discard events outside the viewport
        if self._viewport_contains_dvz_mouse_event(dvz_mouse_event) is False:
            return

        # Read dvz_mouse_event properties
        dvz_event_name: str = dvz_mouse_event.mouse_event()
        dvz_mouse_pos: tuple[float, float] = dvz_mouse_event.pos()
        dvz_mouse_x_px: float = dvz_mouse_pos[0]
        dvz_mouse_y_px: float = self._renderer.get_canvas().get_height() - dvz_mouse_pos[1]
        dvz_button_name: str = dvz_mouse_event.button_name()
        dvz_wheel: float | None = dvz_mouse_event.wheel()

        # Convert fields to our MouseEvent
        if dvz_event_name == "press":
            event_type = EventType.BUTTON_PRESS
        elif dvz_event_name == "release":
            event_type = EventType.BUTTON_RELEASE
        elif dvz_event_name == "move":
            event_type = EventType.MOUSE_MOVE
        elif dvz_event_name == "wheel":
            event_type = EventType.MOUSE_SCROLL
        else:
            print(f'"Unknown dvz mouse event name: {dvz_event_name}"')
            return  # Unknown event

        event_x: float = (dvz_mouse_x_px - self._viewport.get_x()) / self._viewport.get_width() * 2.0 - 1.0
        event_y: float = (dvz_mouse_y_px - self._viewport.get_y()) / self._viewport.get_height() * 2.0 - 1.0

        # print(f"event_x: {event_x}, event_y: {event_y}")
        # print(
        #     f"dvz_mouse_x_px: {dvz_mouse_x_px}, dvz_mouse_y_px: {dvz_mouse_y_px}viewport x:{self._viewport.get_x()}, y:{self._viewport.get_y()}, w:{self._viewport.get_width()}, h:{self._viewport.get_height()}"
        # )

        left_button: bool = dvz_button_name == "left"
        middle_button: bool = dvz_button_name == "middle"
        right_button: bool = dvz_button_name == "right"

        event_scroll_steps: float = dvz_wheel if dvz_wheel is not None else 0.0

        # Create our MouseEvent
        mouse_event = MouseEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=event_type,
            x_ndc=event_x,
            y_ndc=event_y,
            left_button=left_button,
            middle_button=middle_button,
            right_button=right_button,
            scroll_steps=event_scroll_steps,
        )

        # dispatch mouse_event to the proper handler
        if mouse_event.event_type == EventType.BUTTON_PRESS:
            self.button_press_event.dispatch(mouse_event)
        elif mouse_event.event_type == EventType.BUTTON_RELEASE:
            self.button_release_event.dispatch(mouse_event)
        elif mouse_event.event_type == EventType.MOUSE_MOVE:
            self.mouse_move_event.dispatch(mouse_event)
        elif mouse_event.event_type == EventType.MOUSE_SCROLL:
            self.mouse_scroll_event.dispatch(mouse_event)
        else:
            raise ValueError(f"Unknown mouse event type: {mouse_event.event_type}")

    # =============================================================================
    # Connect resize in dvz_app
    # =============================================================================
    @dvz_app.connect(dvz_figure)
    def on_resize(dvz_resize_event: dvz.WindowEvent):
        canvas_width_px = dvz_resize_event.screen_width()  # TODO may be a good idea to rename .screen_width() to .canvas_width() or similar in datoviz
        canvas_height_px = dvz_resize_event.screen_height()
        # dispatch canvas resize event
        canvas_resize_event = CanvasResizeEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=EventType.CANVAS_RESIZE,
            canvas_width_px=canvas_width_px,
            canvas_height_px=canvas_height_px,
        )
        self.canvas_resize_event.dispatch(canvas_resize_event)

close()

Close the event handler and release resources.

Source code in src/gsp_extra/viewport_events/viewport_events_datoviz.py
179
180
181
182
def close(self):
    """Close the event handler and release resources."""
    # no more dispatch events (datoviz doesnt allow to disconnect events)
    self._is_closed = True

Viewport Events Network

gsp_extra.viewport_events.viewport_events_network

"NetworkRenderer event handler for viewport events.

ViewportEventsNetwork

Bases: gsp_extra.viewport_events.viewport_events_base.ViewportEventsBase

NetworkRenderer event handler for viewport.

Code heavily inspired from ViewportEventsMatplotlib

Source code in src/gsp_extra/viewport_events/viewport_events_network.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 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
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
191
192
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
221
222
223
class ViewportEventsNetwork(ViewportEventsBase):
    """NetworkRenderer event handler for viewport.

    Code heavily inspired from ViewportEventsMatplotlib
    """

    __slots__ = [
        "_renderer",
        "_viewport",
        "_has_key_focus",
        "_mpl_key_press_cid",
        "_mpl_key_release_cid",
        "_mpl_button_press_cid",
        "_mpl_button_release_cid",
        "_mpl_mouse_move_cid",
        "_mpl_mouse_scroll_cid",
        "_mpl_resize_cid",
    ]

    def __init__(self, renderer: NetworkRenderer, viewport: Viewport) -> None:
        """Initialize the event handler with a renderer and viewport.

        Args:
            renderer: NetworkRenderer associated with this event handler.
            viewport: Viewport associated with this event handler.
        """
        self._renderer = renderer
        """NetworkRenderer associated with this event handler"""
        self._viewport = viewport
        """viewport associated with this event handler"""
        self._has_key_focus = False
        """True if this viewport has the keyboard focus"""

        # Intanciate events
        self.key_press_event = Event[KeyboardEventCallback]()
        self.key_release_event = Event[KeyboardEventCallback]()
        self.button_press_event = Event[MouseEventCallback]()
        self.button_release_event = Event[MouseEventCallback]()
        self.mouse_move_event = Event[MouseEventCallback]()
        self.mouse_scroll_event = Event[MouseEventCallback]()
        self.canvas_resize_event = Event[CanvasResizeEventCallback]()

        # event connections
        mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
        self._mpl_key_press_cid = mpl_canvas.mpl_connect("key_press_event", typing.cast(Any, self._on_key_press))
        self._mpl_key_release_cid = mpl_canvas.mpl_connect("key_release_event", typing.cast(Any, self._on_key_release))
        self._mpl_button_press_cid = mpl_canvas.mpl_connect("button_press_event", typing.cast(Any, self._on_button_press))
        self._mpl_button_release_cid = mpl_canvas.mpl_connect("button_release_event", typing.cast(Any, self._on_button_release))
        self._mpl_mouse_move_cid = mpl_canvas.mpl_connect("motion_notify_event", typing.cast(Any, self._on_mouse_move))
        self._mpl_mouse_scroll_cid = mpl_canvas.mpl_connect("scroll_event", typing.cast(Any, self._on_mouse_scroll))
        self._mpl_resize_cid = mpl_canvas.mpl_connect("resize_event", typing.cast(Any, self._on_canvas_resize))

    def close(self):
        """Close the event handler and release resources."""
        mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
        if self._mpl_key_press_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_key_press_cid)
            self._mpl_key_press_cid = None
        if self._mpl_key_release_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_key_release_cid)
            self._mpl_key_release_cid = None
        if self._mpl_button_press_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_button_press_cid)
            self._mpl_button_press_cid = None
        if self._mpl_button_release_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_button_release_cid)
            self._mpl_button_release_cid = None
        if self._mpl_mouse_move_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_mouse_move_cid)
            self._mpl_mouse_move_cid = None
        if self._mpl_mouse_scroll_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_mouse_scroll_cid)
            self._mpl_mouse_scroll_cid = None
        if self._mpl_resize_cid is not None:
            mpl_canvas.mpl_disconnect(self._mpl_resize_cid)
            self._mpl_resize_cid = None

    # =============================================================================
    # Matplotlib event handler
    # =============================================================================

    def _on_key_press(self, mpl_key_event: matplotlib.backend_bases.KeyEvent) -> None:
        # discard events outside the viewport
        if self._has_key_focus is False:
            return
        # convert and dispatch event
        keyboard_event = self._mpl_key_event_to_gsp(mpl_key_event, EventType.KEY_PRESS)
        self.key_press_event.dispatch(keyboard_event)

    def _on_key_release(self, mpl_key_event: matplotlib.backend_bases.KeyEvent) -> None:
        # discard events outside the viewport
        if self._has_key_focus is False:
            return
        # convert and dispatch event
        keyboard_event = self._mpl_key_event_to_gsp(mpl_key_event, EventType.KEY_RELEASE)
        self.key_release_event.dispatch(keyboard_event)

    def _on_button_press(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # Set key focus if the event is inside the viewport, otherwise remove key focus
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event):
            self._has_key_focus = True
        else:
            self._has_key_focus = False

        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return

        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.BUTTON_PRESS)
        self.button_press_event.dispatch(mouse_event)

    def _on_button_release(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return
        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.BUTTON_RELEASE)
        self.button_release_event.dispatch(mouse_event)

    def _on_mouse_move(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return
        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.MOUSE_MOVE)
        self.mouse_move_event.dispatch(mouse_event)

    def _on_mouse_scroll(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> None:
        # discard events outside the viewport
        if self._viewport_contains_mpl_mouse_event(mpl_mouse_event) is False:
            return
        # convert and dispatch event
        mouse_event = self._mpl_mouse_event_to_gsp(mpl_mouse_event, EventType.MOUSE_SCROLL)
        self.mouse_scroll_event.dispatch(mouse_event)

    def _on_canvas_resize(self, mpl_resize_event: matplotlib.backend_bases.ResizeEvent) -> None:
        # dispatch canvas resize event
        canvas_resize_event = CanvasResizeEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=EventType.CANVAS_RESIZE,
            canvas_width_px=mpl_resize_event.width,
            canvas_height_px=mpl_resize_event.height,
        )
        self.canvas_resize_event.dispatch(canvas_resize_event)

    # =============================================================================
    #
    # =============================================================================

    def _viewport_contains_mpl_mouse_event(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent) -> bool:
        """Check if the matplotlib mouse event is inside this viewport.

        Args:
            mpl_mouse_event: Matplotlib mouse event.

        Returns:
            True if the mouse event is inside this viewport, False otherwise.
        """
        mouse_x = mpl_mouse_event.x / UnitUtils.device_pixel_ratio()
        mouse_y = mpl_mouse_event.y / UnitUtils.device_pixel_ratio()
        if mouse_x < self._viewport.get_x():
            return False
        if mouse_x >= self._viewport.get_x() + self._viewport.get_width():
            return False
        if mouse_y < self._viewport.get_y():
            return False
        if mouse_y >= self._viewport.get_y() + self._viewport.get_height():
            return False
        return True

    # =============================================================================
    # Conversion matplotlib event to gsp events
    # =============================================================================
    def _mpl_key_event_to_gsp(self, mpl_key_event: matplotlib.backend_bases.KeyEvent, event_type: EventType) -> KeyEvent:
        assert mpl_key_event.key is not None
        keyboard_event = KeyEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=event_type,
            key_name=mpl_key_event.key,
        )
        return keyboard_event

    def _mpl_mouse_event_to_gsp(self, mpl_mouse_event: matplotlib.backend_bases.MouseEvent, event_type: EventType) -> MouseEvent:
        # Sanity check
        assert self._viewport_contains_mpl_mouse_event(mpl_mouse_event), "Mouse event is outside the viewport"

        mouse_x = mpl_mouse_event.x / UnitUtils.device_pixel_ratio()
        mouse_y = mpl_mouse_event.y / UnitUtils.device_pixel_ratio()
        x_viewport = mouse_x - self._viewport.get_x()
        y_viewport = mouse_y - self._viewport.get_y()
        mouse_event = MouseEvent(
            viewport_uuid=self._viewport.get_uuid(),
            event_type=event_type,
            x_ndc=(x_viewport / self._viewport.get_width() - 0.5) * 2.0,
            y_ndc=(y_viewport / self._viewport.get_height() - 0.5) * 2.0,
            left_button=mpl_mouse_event.button == 1,
            middle_button=mpl_mouse_event.button == 2,
            right_button=mpl_mouse_event.button == 3,
            scroll_steps=mpl_mouse_event.step if hasattr(mpl_mouse_event, "step") else 0.0,
        )
        return mouse_event

__init__(renderer: NetworkRenderer, viewport: Viewport) -> None

Initialize the event handler with a renderer and viewport.

Parameters:

Name Type Description Default
renderer gsp_network.renderer.NetworkRenderer

NetworkRenderer associated with this event handler.

required
viewport gsp.core.Viewport

Viewport associated with this event handler.

required
Source code in src/gsp_extra/viewport_events/viewport_events_network.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def __init__(self, renderer: NetworkRenderer, viewport: Viewport) -> None:
    """Initialize the event handler with a renderer and viewport.

    Args:
        renderer: NetworkRenderer associated with this event handler.
        viewport: Viewport associated with this event handler.
    """
    self._renderer = renderer
    """NetworkRenderer associated with this event handler"""
    self._viewport = viewport
    """viewport associated with this event handler"""
    self._has_key_focus = False
    """True if this viewport has the keyboard focus"""

    # Intanciate events
    self.key_press_event = Event[KeyboardEventCallback]()
    self.key_release_event = Event[KeyboardEventCallback]()
    self.button_press_event = Event[MouseEventCallback]()
    self.button_release_event = Event[MouseEventCallback]()
    self.mouse_move_event = Event[MouseEventCallback]()
    self.mouse_scroll_event = Event[MouseEventCallback]()
    self.canvas_resize_event = Event[CanvasResizeEventCallback]()

    # event connections
    mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
    self._mpl_key_press_cid = mpl_canvas.mpl_connect("key_press_event", typing.cast(Any, self._on_key_press))
    self._mpl_key_release_cid = mpl_canvas.mpl_connect("key_release_event", typing.cast(Any, self._on_key_release))
    self._mpl_button_press_cid = mpl_canvas.mpl_connect("button_press_event", typing.cast(Any, self._on_button_press))
    self._mpl_button_release_cid = mpl_canvas.mpl_connect("button_release_event", typing.cast(Any, self._on_button_release))
    self._mpl_mouse_move_cid = mpl_canvas.mpl_connect("motion_notify_event", typing.cast(Any, self._on_mouse_move))
    self._mpl_mouse_scroll_cid = mpl_canvas.mpl_connect("scroll_event", typing.cast(Any, self._on_mouse_scroll))
    self._mpl_resize_cid = mpl_canvas.mpl_connect("resize_event", typing.cast(Any, self._on_canvas_resize))

close()

Close the event handler and release resources.

Source code in src/gsp_extra/viewport_events/viewport_events_network.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def close(self):
    """Close the event handler and release resources."""
    mpl_canvas: matplotlib.backend_bases.FigureCanvasBase = self._renderer.get_mpl_figure().canvas
    if self._mpl_key_press_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_key_press_cid)
        self._mpl_key_press_cid = None
    if self._mpl_key_release_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_key_release_cid)
        self._mpl_key_release_cid = None
    if self._mpl_button_press_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_button_press_cid)
        self._mpl_button_press_cid = None
    if self._mpl_button_release_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_button_release_cid)
        self._mpl_button_release_cid = None
    if self._mpl_mouse_move_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_mouse_move_cid)
        self._mpl_mouse_move_cid = None
    if self._mpl_mouse_scroll_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_mouse_scroll_cid)
        self._mpl_mouse_scroll_cid = None
    if self._mpl_resize_cid is not None:
        mpl_canvas.mpl_disconnect(self._mpl_resize_cid)
        self._mpl_resize_cid = None

Viewport Events Types

gsp_extra.viewport_events.viewport_events_types

Viewport Event Types and Callback Protocols.

CanvasResizeEvent dataclass

Represents a canvas resize event.

Attributes:

Name Type Description
viewport_uuid str

The unique identifier of the viewport affected by the resize.

event_type gsp_extra.viewport_events.viewport_events_types.EventType

The type of event (CANVAS_RESIZE).

canvas_width_px int

The new width of the canvas in pixels.

canvas_height_px int

The new height of the canvas in pixels.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@dataclass
class CanvasResizeEvent:
    """Represents a canvas resize event.

    Attributes:
        viewport_uuid: The unique identifier of the viewport affected by the resize.
        event_type: The type of event (CANVAS_RESIZE).
        canvas_width_px: The new width of the canvas in pixels.
        canvas_height_px: The new height of the canvas in pixels.
    """

    viewport_uuid: str
    event_type: EventType
    canvas_width_px: int
    canvas_height_px: int

CanvasResizeEventCallback

Bases: typing.Protocol

Protocol for canvas resize event callback functions.

Defines the signature for functions that handle canvas resize events.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
101
102
103
104
105
106
107
108
109
class CanvasResizeEventCallback(Protocol):
    """Protocol for canvas resize event callback functions.

    Defines the signature for functions that handle canvas resize events.
    """

    def __call__(self, canvas_resize_event: CanvasResizeEvent) -> None:
        """Handle a canvas resize event."""
        ...

__call__(canvas_resize_event: CanvasResizeEvent) -> None

Handle a canvas resize event.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
107
108
109
def __call__(self, canvas_resize_event: CanvasResizeEvent) -> None:
    """Handle a canvas resize event."""
    ...

EventType

Bases: enum.StrEnum

Enumeration of viewport event types.

Defines the different types of user interaction events that can occur in a viewport, including keyboard, mouse, and canvas events.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class EventType(StrEnum):
    """Enumeration of viewport event types.

    Defines the different types of user interaction events that can occur
    in a viewport, including keyboard, mouse, and canvas events.
    """

    KEY_PRESS = "key_press"
    KEY_RELEASE = "key_release"
    BUTTON_PRESS = "button_press"
    BUTTON_RELEASE = "button_release"
    MOUSE_MOVE = "mouse_move"
    MOUSE_SCROLL = "mouse_scroll"
    CANVAS_RESIZE = "canvas_resize"

KeyEvent dataclass

Represents a keyboard event in a viewport.

Attributes:

Name Type Description
viewport_uuid str

The unique identifier of the viewport where the event occurred.

event_type gsp_extra.viewport_events.viewport_events_types.EventType

The type of keyboard event (KEY_PRESS or KEY_RELEASE).

key_name str

The name of the key that was pressed or released.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
25
26
27
28
29
30
31
32
33
34
35
36
37
@dataclass
class KeyEvent:
    """Represents a keyboard event in a viewport.

    Attributes:
        viewport_uuid: The unique identifier of the viewport where the event occurred.
        event_type: The type of keyboard event (KEY_PRESS or KEY_RELEASE).
        key_name: The name of the key that was pressed or released.
    """

    viewport_uuid: str
    event_type: EventType
    key_name: str

KeyboardEventCallback

Bases: typing.Protocol

Protocol for keyboard event callback functions.

Defines the signature for functions that handle keyboard events.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
80
81
82
83
84
85
86
87
class KeyboardEventCallback(Protocol):
    """Protocol for keyboard event callback functions.

    Defines the signature for functions that handle keyboard events.
    """

    def __call__(self, key_event: KeyEvent) -> None:
        """Handle a keyboard event."""

__call__(key_event: KeyEvent) -> None

Handle a keyboard event.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
86
87
def __call__(self, key_event: KeyEvent) -> None:
    """Handle a keyboard event."""

MouseEvent dataclass

Represents a mouse event in a viewport.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@dataclass
class MouseEvent:
    """Represents a mouse event in a viewport."""

    viewport_uuid: str
    """The unique identifier of the viewport where the event occurred."""
    event_type: EventType
    """The type of mouse event (BUTTON_PRESS, BUTTON_RELEASE, MOUSE_MOVE, or MOUSE_SCROLL)"""
    x_ndc: float
    """The x-coordinate of the mouse position in the viewport in NDC units."""
    y_ndc: float
    """The y-coordinate of the mouse position in the viewport in NDC units."""
    left_button: bool = False
    """Whether the left mouse button is pressed."""
    middle_button: bool = False
    """Whether the middle mouse button is pressed."""
    right_button: bool = False
    """Whether the right mouse button is pressed."""
    scroll_steps: float = 0.0
    """The number of scroll steps (positive for up, negative for down)."""

event_type: EventType instance-attribute

The type of mouse event (BUTTON_PRESS, BUTTON_RELEASE, MOUSE_MOVE, or MOUSE_SCROLL)

left_button: bool = False class-attribute instance-attribute

Whether the left mouse button is pressed.

middle_button: bool = False class-attribute instance-attribute

Whether the middle mouse button is pressed.

right_button: bool = False class-attribute instance-attribute

Whether the right mouse button is pressed.

scroll_steps: float = 0.0 class-attribute instance-attribute

The number of scroll steps (positive for up, negative for down).

viewport_uuid: str instance-attribute

The unique identifier of the viewport where the event occurred.

x_ndc: float instance-attribute

The x-coordinate of the mouse position in the viewport in NDC units.

y_ndc: float instance-attribute

The y-coordinate of the mouse position in the viewport in NDC units.

MouseEventCallback

Bases: typing.Protocol

Protocol for mouse event callback functions.

Defines the signature for functions that handle mouse events.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
90
91
92
93
94
95
96
97
98
class MouseEventCallback(Protocol):
    """Protocol for mouse event callback functions.

    Defines the signature for functions that handle mouse events.
    """

    def __call__(self, mouse_event: MouseEvent) -> None:
        """Handle a mouse event."""
        ...

__call__(mouse_event: MouseEvent) -> None

Handle a mouse event.

Source code in src/gsp_extra/viewport_events/viewport_events_types.py
96
97
98
def __call__(self, mouse_event: MouseEvent) -> None:
    """Handle a mouse event."""
    ...