Skip to content

MeshGlyph Class#

The MeshGlyph class provides visualization for UGRID-style unstructured mesh data using matplotlib triangulation. It supports face-centered and node-centered plotting, wireframe rendering, all 5 color scale types, and time-series animation.

Class Documentation#

cleopatra.mesh_glyph.MeshGlyph #

Bases: Glyph

Visualization class for unstructured mesh data.

Wraps matplotlib's triangulation-based rendering to plot data on UGRID-style unstructured meshes (triangles, quads, mixed polygons). Handles fan triangulation for mixed meshes and maps face-centered values to individual triangles.

Parameters:

Name Type Description Default
node_x ndarray

1D array of node x-coordinates (n_nodes,).

required
node_y ndarray

1D array of node y-coordinates (n_nodes,).

required
face_node_connectivity ndarray

2D array of node indices per face (n_faces, max_nodes_per_face). Use fill_value to pad rows for faces with fewer nodes.

required
fill_value int

Padding value in face_node_connectivity for mixed meshes. Default is -1.

-1
edge_node_connectivity ndarray | None

2D array of node indices per edge (n_edges, 2). If provided, used for efficient wireframe rendering. If None, edges are derived from face connectivity. Default is None.

None

Attributes:

Name Type Description
node_x ndarray

Node x-coordinates.

node_y ndarray

Node y-coordinates.

n_faces int

Number of faces in the mesh.

n_nodes int

Number of nodes in the mesh.

n_edges int

Number of edges (0 if edge connectivity not provided).

Examples:

  • Create a MeshGlyph and inspect its topology:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> node_x = np.array([0.0, 1.0, 0.5])
    >>> node_y = np.array([0.0, 0.0, 1.0])
    >>> faces = np.array([[0, 1, 2]])
    >>> mg = MeshGlyph(node_x, node_y, faces)
    >>> mg.n_faces
    1
    >>> mg.n_nodes
    3
    
Source code in src/cleopatra/mesh_glyph.py
 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
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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
class MeshGlyph(Glyph):
    """Visualization class for unstructured mesh data.

    Wraps matplotlib's triangulation-based rendering to plot data on
    UGRID-style unstructured meshes (triangles, quads, mixed polygons).
    Handles fan triangulation for mixed meshes and maps face-centered
    values to individual triangles.

    Args:
        node_x: 1D array of node x-coordinates (n_nodes,).
        node_y: 1D array of node y-coordinates (n_nodes,).
        face_node_connectivity: 2D array of node indices per face
            (n_faces, max_nodes_per_face). Use ``fill_value`` to pad
            rows for faces with fewer nodes.
        fill_value: Padding value in ``face_node_connectivity`` for
            mixed meshes. Default is -1.
        edge_node_connectivity: 2D array of node indices per edge
            (n_edges, 2). If provided, used for efficient wireframe
            rendering. If None, edges are derived from face
            connectivity. Default is None.

    Attributes:
        node_x: Node x-coordinates.
        node_y: Node y-coordinates.
        n_faces: Number of faces in the mesh.
        n_nodes: Number of nodes in the mesh.
        n_edges: Number of edges (0 if edge connectivity not provided).

    Examples:
        - Create a MeshGlyph and inspect its topology:
            ```python
            >>> import numpy as np
            >>> from cleopatra.mesh_glyph import MeshGlyph
            >>> node_x = np.array([0.0, 1.0, 0.5])
            >>> node_y = np.array([0.0, 0.0, 1.0])
            >>> faces = np.array([[0, 1, 2]])
            >>> mg = MeshGlyph(node_x, node_y, faces)
            >>> mg.n_faces
            1
            >>> mg.n_nodes
            3

            ```
    """

    def __init__(
        self,
        node_x: np.ndarray,
        node_y: np.ndarray,
        face_node_connectivity: np.ndarray,
        fill_value: int = -1,
        edge_node_connectivity: np.ndarray | None = None,
        fig=None,
        ax=None,
        **kwargs,
    ):
        super().__init__(default_options=MESH_DEFAULT_OPTIONS, fig=fig, ax=ax, **kwargs)
        self._node_x = np.asarray(node_x, dtype=np.float64)
        self._node_y = np.asarray(node_y, dtype=np.float64)
        self._face_nodes = np.asarray(face_node_connectivity, dtype=np.intp)
        self._fill_value = fill_value
        self._edge_nodes = (
            np.asarray(edge_node_connectivity, dtype=np.intp)
            if edge_node_connectivity is not None
            else None
        )

        if self._node_x.ndim != 1:
            raise ValueError(f"node_x must be 1D, got {self._node_x.ndim}D.")
        if self._node_x.shape != self._node_y.shape:
            raise ValueError(
                f"node_x and node_y must have the same shape, "
                f"got {self._node_x.shape} and {self._node_y.shape}."
            )
        if self._face_nodes.ndim != 2:
            raise ValueError(
                f"face_node_connectivity must be 2D, got {self._face_nodes.ndim}D."
            )
        valid_indices = self._face_nodes[self._face_nodes != self._fill_value]
        if len(valid_indices) > 0:
            if valid_indices.min() < 0 or valid_indices.max() >= self.n_nodes:
                raise ValueError(
                    f"face_node_connectivity indices must be in "
                    f"[0, {self.n_nodes}), got range "
                    f"[{valid_indices.min()}, {valid_indices.max()}]."
                )
        if self._edge_nodes is not None:
            if self._edge_nodes.ndim != 2 or self._edge_nodes.shape[1] != 2:
                raise ValueError(
                    f"edge_node_connectivity must have shape (n_edges, 2), "
                    f"got {self._edge_nodes.shape}."
                )

        self._cached_triangulation: mtri.Triangulation | None = None
        self._cached_tri_array: np.ndarray | None = None
        self._cached_nodes_per_face: np.ndarray | None = None
        self._cbar = None

    @property
    def node_x(self) -> np.ndarray:
        """Node x-coordinates."""
        return self._node_x

    @property
    def node_y(self) -> np.ndarray:
        """Node y-coordinates."""
        return self._node_y

    @property
    def n_faces(self) -> int:
        """Number of faces in the mesh."""
        return self._face_nodes.shape[0]

    @property
    def n_nodes(self) -> int:
        """Number of nodes in the mesh."""
        return len(self._node_x)

    @property
    def n_edges(self) -> int:
        """Number of edges (0 if edge connectivity not provided)."""
        return self._edge_nodes.shape[0] if self._edge_nodes is not None else 0

    @property
    def nodes_per_face(self) -> np.ndarray:
        """Number of valid nodes per face (excluding fill values).

        Returns:
            np.ndarray: 1D integer array of length n_faces.

        Examples:
            - Pure triangular mesh returns all 3s:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> mg = MeshGlyph(
                ...     np.array([0.0, 1.0, 0.5, 1.5]),
                ...     np.array([0.0, 0.0, 1.0, 1.0]),
                ...     np.array([[0, 1, 2], [1, 3, 2]]),
                ... )
                >>> mg.nodes_per_face
                array([3, 3])

                ```
            - Mixed mesh with quads and triangles:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> mg = MeshGlyph(
                ...     np.array([0.0, 1.0, 2.0, 0.0, 1.0, 2.0]),
                ...     np.array([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
                ...     np.array([[0, 1, 4, 3], [1, 2, 5, -1]]),
                ...     fill_value=-1,
                ... )
                >>> mg.nodes_per_face
                array([4, 3])

                ```
        """
        if self._cached_nodes_per_face is None:
            self._cached_nodes_per_face = np.sum(
                self._face_nodes != self._fill_value, axis=1
            ).astype(np.intp)
        return self._cached_nodes_per_face

    @property
    def triangulation(self) -> mtri.Triangulation:
        """Matplotlib Triangulation built via fan decomposition.

        Each face with N valid nodes is decomposed into (N-2)
        triangles by fanning from the first vertex. Faces with
        fewer than 3 valid nodes are skipped.

        Returns:
            matplotlib.tri.Triangulation: Triangulation ready for
                tripcolor/tricontourf.

        Raises:
            ValueError: If no faces have 3 or more valid nodes.

        Examples:
            - Build a triangulation and check its shape:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> mg = MeshGlyph(
                ...     np.array([0.0, 1.0, 0.5]),
                ...     np.array([0.0, 0.0, 1.0]),
                ...     np.array([[0, 1, 2]]),
                ... )
                >>> tri = mg.triangulation
                >>> tri.triangles.shape
                (1, 3)

                ```
        """
        if self._cached_triangulation is None:
            tri_array = self._fan_triangles()
            self._cached_triangulation = mtri.Triangulation(
                self._node_x, self._node_y, tri_array
            )
        return self._cached_triangulation

    def _fan_triangles(self) -> np.ndarray:
        """Compute fan triangulation for mixed-element meshes.

        Each face with N valid nodes is decomposed into (N-2) triangles
        using fan decomposition from the first vertex. Pure-triangle
        meshes use a fast path that returns the connectivity directly.

        Returns:
            np.ndarray: (n_triangles, 3) array of node indices.

        Raises:
            ValueError: If no valid triangles can be formed.
        """
        if self._cached_tri_array is not None:
            return self._cached_tri_array

        counts = self.nodes_per_face

        if not np.any(counts >= 3):
            raise ValueError("Cannot create triangulation: no faces with 3+ nodes.")

        if np.all(counts == 3):
            self._cached_tri_array = self._face_nodes.copy()
            return self._cached_tri_array

        triangles: list[list[int]] = []
        for i in range(self.n_faces):
            row = self._face_nodes[i]
            nodes = row[row != self._fill_value]
            n = len(nodes)
            if n < 3:
                continue
            for j in range(1, n - 1):
                triangles.append([int(nodes[0]), int(nodes[j]), int(nodes[j + 1])])

        self._cached_tri_array = np.array(triangles, dtype=np.intp)
        return self._cached_tri_array

    def _map_face_to_triangle_values(self, face_values: np.ndarray) -> np.ndarray:
        """Map per-face values to per-triangle values.

        Each original face may produce multiple triangles via fan
        decomposition. All triangles from the same face receive
        the same data value.

        Args:
            face_values: 1D array of values, one per face.

        Returns:
            np.ndarray: 1D array of values, one per triangle.

        Examples:
            - Quad face produces 2 triangles with the same value:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> mg = MeshGlyph(
                ...     np.array([0.0, 1.0, 1.0, 0.0]),
                ...     np.array([0.0, 0.0, 1.0, 1.0]),
                ...     np.array([[0, 1, 2, 3]]),
                ... )
                >>> mg._map_face_to_triangle_values(np.array([42.0]))
                array([42., 42.])

                ```
        """
        counts = self.nodes_per_face
        valid = counts >= 3
        return np.repeat(face_values[valid], counts[valid] - 2)

    def _validate_location_and_data(self, data: np.ndarray, location: str) -> None:
        """Validate location string and data length."""
        if location not in ("face", "node"):
            raise ValueError(
                f"Plotting not supported for location='{location}'. "
                f"Use 'face' or 'node'."
            )
        expected = self.n_faces if location == "face" else self.n_nodes
        if len(data) != expected:
            raise ValueError(
                f"data length ({len(data)}) does not match "
                f"n_{location}s ({expected})."
            )

    def _render_mesh(
        self,
        ax,
        data: np.ndarray,
        location: str,
        edgecolor: str = "none",
        norm=None,
        **render_kwargs,
    ):
        """Render mesh data on axes and return the mappable.

        Args:
            ax: Matplotlib axes.
            data: 1D data array.
            location: ``"face"`` or ``"node"``.
            edgecolor: Edge color for face rendering.
            norm: Color normalization.
            **render_kwargs: Passed to tripcolor or tricontourf.

        Returns:
            ScalarMappable: The tripcolor or tricontourf result.
        """
        tri = self.triangulation
        cmap = self.default_options["cmap"]
        vmin = self.default_options["vmin"]
        vmax = self.default_options["vmax"]

        if location == "face":
            tri_values = self._map_face_to_triangle_values(data)
            kw: dict[str, Any] = {"cmap": cmap, "edgecolors": edgecolor}
            if norm is not None:
                kw["norm"] = norm
            else:
                kw["vmin"] = vmin
                kw["vmax"] = vmax
            kw.update(render_kwargs)
            return ax.tripcolor(tri, facecolors=tri_values, **kw)

        contour_kw: dict[str, Any] = {"cmap": cmap, "levels": 20}
        if norm is not None:
            contour_kw["norm"] = norm
        else:
            if vmin is not None:
                contour_kw["vmin"] = vmin
            if vmax is not None:
                contour_kw["vmax"] = vmax
        contour_kw.update(render_kwargs)
        return ax.tricontourf(tri, data, **contour_kw)

    def plot(
        self,
        data: np.ndarray,
        location: str = "face",
        ax: Any = None,
        edgecolor: str = "none",
        colorbar: bool = True,
        title: str | None = None,
        **kwargs: Any,
    ) -> tuple[plt.Figure, plt.Axes]:
        """Plot mesh data using matplotlib triangulation.

        For face-centered data, uses ``tripcolor`` where each triangle
        is colored by the value of its parent face. For node-centered
        data, uses ``tricontourf`` for smooth interpolated contours.

        Supports all 5 color scale types from ``default_options``:
        linear, power, sym-lognorm, boundary-norm, and midpoint.

        Args:
            data: 1D data array. Length must match face count
                (location="face") or node count (location="node").
            location: Mesh element location: ``"face"`` or ``"node"``.
                Default is ``"face"``.
            ax: Axes to plot on. If None, uses stored axes or creates
                new.
            edgecolor: Edge color for face rendering. Default is
                ``"none"``.
            colorbar: Whether to add a colorbar. Default is True.
            title: Plot title. Overrides ``default_options["title"]``.
            **kwargs: Override any key in ``default_options`` (cmap,
                vmin, vmax, color_scale, gamma, midpoint, bounds,
                ticks_spacing, cbar_orientation, cbar_label, figsize,
                etc.) or pass extra rendering kwargs (levels for
                tricontourf).

        Returns:
            tuple[Figure, Axes]: The matplotlib Figure and Axes objects.
                When no axes exist, a new figure is created. Call
                ``plt.close(fig)`` after saving to avoid memory leaks
                in batch processing.

        Raises:
            ValueError: If ``location`` is not ``"face"`` or ``"node"``,
                or if ``data`` length does not match the expected mesh
                dimension.

        Examples:
            - Plot face-centered data:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
                >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
                >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
                >>> mg = MeshGlyph(node_x, node_y, faces)
                >>> fig, ax = mg.plot(np.array([1.0, 2.0]))

                ```
            - Plot node-centered data:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
                >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
                >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
                >>> mg = MeshGlyph(node_x, node_y, faces)
                >>> fig, ax = mg.plot(
                ...     np.array([0.0, 1.0, 2.0, 3.0]),
                ...     location="node",
                ... )

                ```
            - Plot with power color scale:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
                >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
                >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
                >>> mg = MeshGlyph(node_x, node_y, faces)
                >>> fig, ax = mg.plot(
                ...     np.array([1.0, 2.0]),
                ...     color_scale="power",
                ...     gamma=0.5,
                ...     cmap="coolwarm",
                ... )

                ```
        """
        self._validate_location_and_data(data, location)

        # Guard against all-NaN data.
        if np.all(np.isnan(data)):
            raise ValueError(
                "data is entirely NaN, cannot determine color range."
            )

        # Reset default_options to a fresh copy so repeated plot() calls
        # on the same instance don't accumulate stale overrides.
        self._default_options = MESH_DEFAULT_OPTIONS.copy()

        # Separate rendering kwargs (e.g. levels) from default_options kwargs.
        render_kwargs: dict[str, Any] = {}
        option_kwargs: dict[str, Any] = {}
        for key, val in kwargs.items():
            if key in self.default_options:
                option_kwargs[key] = val
            else:
                render_kwargs[key] = val
        self._merge_kwargs(option_kwargs)

        # Recompute vmin/vmax from data unless user explicitly passed them.
        if "vmin" not in option_kwargs:
            self.default_options["vmin"] = float(np.nanmin(data))
        if "vmax" not in option_kwargs:
            self.default_options["vmax"] = float(np.nanmax(data))
        self._vmin = self.default_options["vmin"]
        self._vmax = self.default_options["vmax"]

        # Compute ticks_spacing and write it to default_options for get_ticks().
        if "ticks_spacing" not in option_kwargs:
            spacing = (self._vmax - self._vmin) / 10
            self.default_options["ticks_spacing"] = max(spacing, 1e-10)
        self.ticks_spacing = self.default_options["ticks_spacing"]

        if title is not None:
            self.default_options["title"] = title

        if ax is not None:
            self.ax = ax
            self.fig = ax.get_figure()
        elif self.fig is None:
            self.fig, self.ax = self.create_figure_axes()

        ticks = self.get_ticks()
        norm, cbar_kw = self._create_norm_and_cbar_kw(ticks)

        tpc = self._render_mesh(
            self.ax,
            data,
            location,
            edgecolor=edgecolor,
            norm=norm,
            **render_kwargs,
        )

        # Remove previous colorbar before adding a new one.
        if self._cbar is not None:
            self._cbar.remove()
            self._cbar = None

        if colorbar:
            self._cbar = self.create_color_bar(self.ax, tpc, cbar_kw)

        if self.default_options["title"]:
            self.ax.set_title(
                self.default_options["title"],
                fontsize=self.default_options["title_size"],
            )
        self.ax.set_aspect("equal")

        return self.fig, self.ax

    def animate(
        self,
        data: np.ndarray | list[np.ndarray],
        time: list[Any],
        location: str = "face",
        edgecolor: str = "none",
        interval: int = 200,
        text_loc: list | None = None,
        **kwargs: Any,
    ) -> FuncAnimation:
        """Create an animation from time-varying mesh data.

        Iterates over the first dimension of ``data`` (or elements of a
        list), rendering each frame on the fixed mesh topology.

        Args:
            data: Sequence of data arrays. If a 2D ndarray of shape
                ``(n_frames, n_elements)``, each row is one frame.
                If a list, each element is a 1D array for one frame.
            time: Labels for each frame (timestamps, strings, etc.).
                Length must match the number of frames.
            location: ``"face"`` or ``"node"``. Default is ``"face"``.
            edgecolor: Edge color for face rendering. Default is
                ``"none"``.
            interval: Milliseconds between frames. Default is 200.
            text_loc: ``[x, y]`` position for the time label text.
                Default is ``[0.1, 0.2]``.
            **kwargs: Override any key in ``default_options`` (cmap,
                vmin, vmax, color_scale, gamma, midpoint,
                ticks_spacing, cbar_label, cbar_orientation, figsize,
                title, etc.).

        Returns:
            FuncAnimation: The animation object. Use
                ``save_animation()`` to export.

        Raises:
            ValueError: If ``data`` frames don't match mesh topology
                or ``time`` length doesn't match frame count.

        Examples:
            - Animate face data over 3 time steps:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
                >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
                >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
                >>> mg = MeshGlyph(node_x, node_y, faces)
                >>> frames = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
                >>> anim = mg.animate(frames, time=["t0", "t1", "t2"])

                ```
        """
        if text_loc is None:
            text_loc = [0.1, 0.2]

        # Normalize data to a list of 1D arrays.
        if isinstance(data, np.ndarray) and data.ndim == 2:
            frames = [data[i] for i in range(data.shape[0])]
        else:
            frames = list(data)

        n_frames = len(frames)
        if len(time) != n_frames:
            raise ValueError(
                f"time length ({len(time)}) does not match "
                f"frame count ({n_frames})."
            )
        expected = self.n_faces if location == "face" else self.n_nodes
        for i, frame in enumerate(frames):
            if len(frame) != expected:
                raise ValueError(
                    f"Frame {i}: data length ({len(frame)}) does not "
                    f"match n_{location}s ({expected})."
                )

        # Reset default_options to a fresh copy.
        self._default_options = MESH_DEFAULT_OPTIONS.copy()
        self._merge_kwargs(kwargs)

        # Compute global vmin/vmax across all frames unless user set them.
        if "vmin" not in kwargs:
            global_min = min(float(np.nanmin(f)) for f in frames)
            self.default_options["vmin"] = global_min
        if "vmax" not in kwargs:
            global_max = max(float(np.nanmax(f)) for f in frames)
            self.default_options["vmax"] = global_max
        self._vmin = self.default_options["vmin"]
        self._vmax = self.default_options["vmax"]

        # Compute ticks_spacing and write it to default_options for get_ticks().
        if "ticks_spacing" not in kwargs:
            spacing = (self._vmax - self._vmin) / 10
            self.default_options["ticks_spacing"] = max(spacing, 1e-10)
        self.ticks_spacing = self.default_options["ticks_spacing"]

        if self.fig is None:
            self.fig, self.ax = self.create_figure_axes()
        fig, ax = self.fig, self.ax

        ticks = self.get_ticks()
        norm, cbar_kw = self._create_norm_and_cbar_kw(ticks)

        # Render the first frame.
        tpc = self._render_mesh(
            ax,
            frames[0],
            location,
            edgecolor=edgecolor,
            norm=norm,
        )
        self.create_color_bar(ax, tpc, cbar_kw)

        if self.default_options["title"]:
            ax.set_title(
                self.default_options["title"],
                fontsize=self.default_options["title_size"],
            )
        ax.set_aspect("equal")

        day_text = ax.text(
            text_loc[0],
            text_loc[1],
            " ",
            fontsize=self.default_options["cbar_label_size"],
            transform=ax.transAxes,
        )

        # Track the current mappable so we can remove it cleanly.
        current_mappable = [tpc]

        def _update(i):
            """Update the plot for frame i."""
            prev = current_mappable[0]
            if hasattr(prev, "collections"):
                for coll in prev.collections:
                    coll.remove()
            elif hasattr(prev, "remove"):
                prev.remove()
            current_mappable[0] = self._render_mesh(
                ax,
                frames[i],
                location,
                edgecolor=edgecolor,
                norm=norm,
            )
            day_text.set_text(str(time[i]))

        plt.tight_layout()
        anim = FuncAnimation(
            fig,
            _update,
            frames=n_frames,
            interval=interval,
            blit=False,
        )
        self._anim = anim
        return anim

    def plot_outline(
        self,
        ax: Any = None,
        color: str = "black",
        linewidth: float = 0.3,
        figsize: tuple[int, int] = (10, 8),
        **kwargs: Any,
    ) -> tuple[plt.Figure, plt.Axes]:
        """Plot mesh edges as a wireframe.

        Uses ``matplotlib.collections.LineCollection`` for efficient
        rendering of thousands of edges.

        Args:
            ax: Axes to plot on. If None, uses stored axes or creates
                new.
            color: Edge color. Default is ``"black"``.
            linewidth: Edge line width. Default is ``0.3``.
            figsize: Figure size in inches. Default is ``(10, 8)``.
            **kwargs: Additional keyword arguments passed to
                ``LineCollection``.

        Returns:
            tuple[Figure, Axes]: The matplotlib Figure and Axes objects.
                When ``ax`` is None, a new figure is created. Call
                ``plt.close(fig)`` after saving to avoid memory leaks
                in batch processing.

        Examples:
            - Render a triangular mesh wireframe:
                ```python
                >>> import numpy as np
                >>> from cleopatra.mesh_glyph import MeshGlyph
                >>> mg = MeshGlyph(
                ...     np.array([0.0, 1.0, 0.5]),
                ...     np.array([0.0, 0.0, 1.0]),
                ...     np.array([[0, 1, 2]]),
                ... )
                >>> fig, ax = mg.plot_outline(color="blue")

                ```
        """
        if ax is not None:
            self.ax = ax
            self.fig = ax.get_figure()
        elif self.fig is None:
            self.fig, self.ax = plt.subplots(1, 1, figsize=figsize)

        segments = self._build_edge_segments()

        lc = mcoll.LineCollection(
            segments, colors=color, linewidths=linewidth, **kwargs
        )
        self.ax.add_collection(lc)
        self.ax.autoscale()
        self.ax.set_aspect("equal")

        return self.fig, self.ax

    def _build_edge_segments(self) -> np.ndarray:
        """Build line segments for wireframe rendering.

        Uses edge_node_connectivity if available (vectorized), otherwise
        derives unique edges from face_node_connectivity using a set for
        deduplication.

        Returns:
            np.ndarray: Array of shape (n_segments, 2, 2) where each
                segment is ``[[x1, y1], [x2, y2]]``. Returns an empty
                array with shape (0, 2, 2) if no edges can be derived.
        """
        if self._edge_nodes is not None:
            n1 = self._edge_nodes[:, 0]
            n2 = self._edge_nodes[:, 1]
            starts = np.column_stack([self._node_x[n1], self._node_y[n1]])
            ends = np.column_stack([self._node_x[n2], self._node_y[n2]])
            return np.stack([starts, ends], axis=1)

        edges: set[tuple[int, int]] = set()
        for i in range(self.n_faces):
            row = self._face_nodes[i]
            nodes = row[row != self._fill_value]
            n = len(nodes)
            for j in range(n):
                a, b = int(nodes[j]), int(nodes[(j + 1) % n])
                key = (min(a, b), max(a, b))
                edges.add(key)

        if not edges:
            return np.empty((0, 2, 2), dtype=np.float64)

        edge_arr = np.array(list(edges), dtype=np.intp)
        n1, n2 = edge_arr[:, 0], edge_arr[:, 1]
        starts = np.column_stack([self._node_x[n1], self._node_y[n1]])
        ends = np.column_stack([self._node_x[n2], self._node_y[n2]])
        return np.stack([starts, ends], axis=1)

n_edges property #

Number of edges (0 if edge connectivity not provided).

n_faces property #

Number of faces in the mesh.

n_nodes property #

Number of nodes in the mesh.

node_x property #

Node x-coordinates.

node_y property #

Node y-coordinates.

nodes_per_face property #

Number of valid nodes per face (excluding fill values).

Returns:

Type Description
ndarray

np.ndarray: 1D integer array of length n_faces.

Examples:

  • Pure triangular mesh returns all 3s:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> mg = MeshGlyph(
    ...     np.array([0.0, 1.0, 0.5, 1.5]),
    ...     np.array([0.0, 0.0, 1.0, 1.0]),
    ...     np.array([[0, 1, 2], [1, 3, 2]]),
    ... )
    >>> mg.nodes_per_face
    array([3, 3])
    
  • Mixed mesh with quads and triangles:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> mg = MeshGlyph(
    ...     np.array([0.0, 1.0, 2.0, 0.0, 1.0, 2.0]),
    ...     np.array([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]),
    ...     np.array([[0, 1, 4, 3], [1, 2, 5, -1]]),
    ...     fill_value=-1,
    ... )
    >>> mg.nodes_per_face
    array([4, 3])
    

triangulation property #

Matplotlib Triangulation built via fan decomposition.

Each face with N valid nodes is decomposed into (N-2) triangles by fanning from the first vertex. Faces with fewer than 3 valid nodes are skipped.

Returns:

Type Description
Triangulation

matplotlib.tri.Triangulation: Triangulation ready for tripcolor/tricontourf.

Raises:

Type Description
ValueError

If no faces have 3 or more valid nodes.

Examples:

  • Build a triangulation and check its shape:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> mg = MeshGlyph(
    ...     np.array([0.0, 1.0, 0.5]),
    ...     np.array([0.0, 0.0, 1.0]),
    ...     np.array([[0, 1, 2]]),
    ... )
    >>> tri = mg.triangulation
    >>> tri.triangles.shape
    (1, 3)
    

animate(data, time, location='face', edgecolor='none', interval=200, text_loc=None, **kwargs) #

Create an animation from time-varying mesh data.

Iterates over the first dimension of data (or elements of a list), rendering each frame on the fixed mesh topology.

Parameters:

Name Type Description Default
data ndarray | list[ndarray]

Sequence of data arrays. If a 2D ndarray of shape (n_frames, n_elements), each row is one frame. If a list, each element is a 1D array for one frame.

required
time list[Any]

Labels for each frame (timestamps, strings, etc.). Length must match the number of frames.

required
location str

"face" or "node". Default is "face".

'face'
edgecolor str

Edge color for face rendering. Default is "none".

'none'
interval int

Milliseconds between frames. Default is 200.

200
text_loc list | None

[x, y] position for the time label text. Default is [0.1, 0.2].

None
**kwargs Any

Override any key in default_options (cmap, vmin, vmax, color_scale, gamma, midpoint, ticks_spacing, cbar_label, cbar_orientation, figsize, title, etc.).

{}

Returns:

Name Type Description
FuncAnimation FuncAnimation

The animation object. Use save_animation() to export.

Raises:

Type Description
ValueError

If data frames don't match mesh topology or time length doesn't match frame count.

Examples:

  • Animate face data over 3 time steps:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
    >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
    >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
    >>> mg = MeshGlyph(node_x, node_y, faces)
    >>> frames = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
    >>> anim = mg.animate(frames, time=["t0", "t1", "t2"])
    
Source code in src/cleopatra/mesh_glyph.py
def animate(
    self,
    data: np.ndarray | list[np.ndarray],
    time: list[Any],
    location: str = "face",
    edgecolor: str = "none",
    interval: int = 200,
    text_loc: list | None = None,
    **kwargs: Any,
) -> FuncAnimation:
    """Create an animation from time-varying mesh data.

    Iterates over the first dimension of ``data`` (or elements of a
    list), rendering each frame on the fixed mesh topology.

    Args:
        data: Sequence of data arrays. If a 2D ndarray of shape
            ``(n_frames, n_elements)``, each row is one frame.
            If a list, each element is a 1D array for one frame.
        time: Labels for each frame (timestamps, strings, etc.).
            Length must match the number of frames.
        location: ``"face"`` or ``"node"``. Default is ``"face"``.
        edgecolor: Edge color for face rendering. Default is
            ``"none"``.
        interval: Milliseconds between frames. Default is 200.
        text_loc: ``[x, y]`` position for the time label text.
            Default is ``[0.1, 0.2]``.
        **kwargs: Override any key in ``default_options`` (cmap,
            vmin, vmax, color_scale, gamma, midpoint,
            ticks_spacing, cbar_label, cbar_orientation, figsize,
            title, etc.).

    Returns:
        FuncAnimation: The animation object. Use
            ``save_animation()`` to export.

    Raises:
        ValueError: If ``data`` frames don't match mesh topology
            or ``time`` length doesn't match frame count.

    Examples:
        - Animate face data over 3 time steps:
            ```python
            >>> import numpy as np
            >>> from cleopatra.mesh_glyph import MeshGlyph
            >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
            >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
            >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
            >>> mg = MeshGlyph(node_x, node_y, faces)
            >>> frames = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
            >>> anim = mg.animate(frames, time=["t0", "t1", "t2"])

            ```
    """
    if text_loc is None:
        text_loc = [0.1, 0.2]

    # Normalize data to a list of 1D arrays.
    if isinstance(data, np.ndarray) and data.ndim == 2:
        frames = [data[i] for i in range(data.shape[0])]
    else:
        frames = list(data)

    n_frames = len(frames)
    if len(time) != n_frames:
        raise ValueError(
            f"time length ({len(time)}) does not match "
            f"frame count ({n_frames})."
        )
    expected = self.n_faces if location == "face" else self.n_nodes
    for i, frame in enumerate(frames):
        if len(frame) != expected:
            raise ValueError(
                f"Frame {i}: data length ({len(frame)}) does not "
                f"match n_{location}s ({expected})."
            )

    # Reset default_options to a fresh copy.
    self._default_options = MESH_DEFAULT_OPTIONS.copy()
    self._merge_kwargs(kwargs)

    # Compute global vmin/vmax across all frames unless user set them.
    if "vmin" not in kwargs:
        global_min = min(float(np.nanmin(f)) for f in frames)
        self.default_options["vmin"] = global_min
    if "vmax" not in kwargs:
        global_max = max(float(np.nanmax(f)) for f in frames)
        self.default_options["vmax"] = global_max
    self._vmin = self.default_options["vmin"]
    self._vmax = self.default_options["vmax"]

    # Compute ticks_spacing and write it to default_options for get_ticks().
    if "ticks_spacing" not in kwargs:
        spacing = (self._vmax - self._vmin) / 10
        self.default_options["ticks_spacing"] = max(spacing, 1e-10)
    self.ticks_spacing = self.default_options["ticks_spacing"]

    if self.fig is None:
        self.fig, self.ax = self.create_figure_axes()
    fig, ax = self.fig, self.ax

    ticks = self.get_ticks()
    norm, cbar_kw = self._create_norm_and_cbar_kw(ticks)

    # Render the first frame.
    tpc = self._render_mesh(
        ax,
        frames[0],
        location,
        edgecolor=edgecolor,
        norm=norm,
    )
    self.create_color_bar(ax, tpc, cbar_kw)

    if self.default_options["title"]:
        ax.set_title(
            self.default_options["title"],
            fontsize=self.default_options["title_size"],
        )
    ax.set_aspect("equal")

    day_text = ax.text(
        text_loc[0],
        text_loc[1],
        " ",
        fontsize=self.default_options["cbar_label_size"],
        transform=ax.transAxes,
    )

    # Track the current mappable so we can remove it cleanly.
    current_mappable = [tpc]

    def _update(i):
        """Update the plot for frame i."""
        prev = current_mappable[0]
        if hasattr(prev, "collections"):
            for coll in prev.collections:
                coll.remove()
        elif hasattr(prev, "remove"):
            prev.remove()
        current_mappable[0] = self._render_mesh(
            ax,
            frames[i],
            location,
            edgecolor=edgecolor,
            norm=norm,
        )
        day_text.set_text(str(time[i]))

    plt.tight_layout()
    anim = FuncAnimation(
        fig,
        _update,
        frames=n_frames,
        interval=interval,
        blit=False,
    )
    self._anim = anim
    return anim

plot(data, location='face', ax=None, edgecolor='none', colorbar=True, title=None, **kwargs) #

Plot mesh data using matplotlib triangulation.

For face-centered data, uses tripcolor where each triangle is colored by the value of its parent face. For node-centered data, uses tricontourf for smooth interpolated contours.

Supports all 5 color scale types from default_options: linear, power, sym-lognorm, boundary-norm, and midpoint.

Parameters:

Name Type Description Default
data ndarray

1D data array. Length must match face count (location="face") or node count (location="node").

required
location str

Mesh element location: "face" or "node". Default is "face".

'face'
ax Any

Axes to plot on. If None, uses stored axes or creates new.

None
edgecolor str

Edge color for face rendering. Default is "none".

'none'
colorbar bool

Whether to add a colorbar. Default is True.

True
title str | None

Plot title. Overrides default_options["title"].

None
**kwargs Any

Override any key in default_options (cmap, vmin, vmax, color_scale, gamma, midpoint, bounds, ticks_spacing, cbar_orientation, cbar_label, figsize, etc.) or pass extra rendering kwargs (levels for tricontourf).

{}

Returns:

Type Description
tuple[Figure, Axes]

tuple[Figure, Axes]: The matplotlib Figure and Axes objects. When no axes exist, a new figure is created. Call plt.close(fig) after saving to avoid memory leaks in batch processing.

Raises:

Type Description
ValueError

If location is not "face" or "node", or if data length does not match the expected mesh dimension.

Examples:

  • Plot face-centered data:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
    >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
    >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
    >>> mg = MeshGlyph(node_x, node_y, faces)
    >>> fig, ax = mg.plot(np.array([1.0, 2.0]))
    
  • Plot node-centered data:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
    >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
    >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
    >>> mg = MeshGlyph(node_x, node_y, faces)
    >>> fig, ax = mg.plot(
    ...     np.array([0.0, 1.0, 2.0, 3.0]),
    ...     location="node",
    ... )
    
  • Plot with power color scale:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
    >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
    >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
    >>> mg = MeshGlyph(node_x, node_y, faces)
    >>> fig, ax = mg.plot(
    ...     np.array([1.0, 2.0]),
    ...     color_scale="power",
    ...     gamma=0.5,
    ...     cmap="coolwarm",
    ... )
    
Source code in src/cleopatra/mesh_glyph.py
def plot(
    self,
    data: np.ndarray,
    location: str = "face",
    ax: Any = None,
    edgecolor: str = "none",
    colorbar: bool = True,
    title: str | None = None,
    **kwargs: Any,
) -> tuple[plt.Figure, plt.Axes]:
    """Plot mesh data using matplotlib triangulation.

    For face-centered data, uses ``tripcolor`` where each triangle
    is colored by the value of its parent face. For node-centered
    data, uses ``tricontourf`` for smooth interpolated contours.

    Supports all 5 color scale types from ``default_options``:
    linear, power, sym-lognorm, boundary-norm, and midpoint.

    Args:
        data: 1D data array. Length must match face count
            (location="face") or node count (location="node").
        location: Mesh element location: ``"face"`` or ``"node"``.
            Default is ``"face"``.
        ax: Axes to plot on. If None, uses stored axes or creates
            new.
        edgecolor: Edge color for face rendering. Default is
            ``"none"``.
        colorbar: Whether to add a colorbar. Default is True.
        title: Plot title. Overrides ``default_options["title"]``.
        **kwargs: Override any key in ``default_options`` (cmap,
            vmin, vmax, color_scale, gamma, midpoint, bounds,
            ticks_spacing, cbar_orientation, cbar_label, figsize,
            etc.) or pass extra rendering kwargs (levels for
            tricontourf).

    Returns:
        tuple[Figure, Axes]: The matplotlib Figure and Axes objects.
            When no axes exist, a new figure is created. Call
            ``plt.close(fig)`` after saving to avoid memory leaks
            in batch processing.

    Raises:
        ValueError: If ``location`` is not ``"face"`` or ``"node"``,
            or if ``data`` length does not match the expected mesh
            dimension.

    Examples:
        - Plot face-centered data:
            ```python
            >>> import numpy as np
            >>> from cleopatra.mesh_glyph import MeshGlyph
            >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
            >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
            >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
            >>> mg = MeshGlyph(node_x, node_y, faces)
            >>> fig, ax = mg.plot(np.array([1.0, 2.0]))

            ```
        - Plot node-centered data:
            ```python
            >>> import numpy as np
            >>> from cleopatra.mesh_glyph import MeshGlyph
            >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
            >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
            >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
            >>> mg = MeshGlyph(node_x, node_y, faces)
            >>> fig, ax = mg.plot(
            ...     np.array([0.0, 1.0, 2.0, 3.0]),
            ...     location="node",
            ... )

            ```
        - Plot with power color scale:
            ```python
            >>> import numpy as np
            >>> from cleopatra.mesh_glyph import MeshGlyph
            >>> node_x = np.array([0.0, 1.0, 0.5, 1.5])
            >>> node_y = np.array([0.0, 0.0, 1.0, 1.0])
            >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
            >>> mg = MeshGlyph(node_x, node_y, faces)
            >>> fig, ax = mg.plot(
            ...     np.array([1.0, 2.0]),
            ...     color_scale="power",
            ...     gamma=0.5,
            ...     cmap="coolwarm",
            ... )

            ```
    """
    self._validate_location_and_data(data, location)

    # Guard against all-NaN data.
    if np.all(np.isnan(data)):
        raise ValueError(
            "data is entirely NaN, cannot determine color range."
        )

    # Reset default_options to a fresh copy so repeated plot() calls
    # on the same instance don't accumulate stale overrides.
    self._default_options = MESH_DEFAULT_OPTIONS.copy()

    # Separate rendering kwargs (e.g. levels) from default_options kwargs.
    render_kwargs: dict[str, Any] = {}
    option_kwargs: dict[str, Any] = {}
    for key, val in kwargs.items():
        if key in self.default_options:
            option_kwargs[key] = val
        else:
            render_kwargs[key] = val
    self._merge_kwargs(option_kwargs)

    # Recompute vmin/vmax from data unless user explicitly passed them.
    if "vmin" not in option_kwargs:
        self.default_options["vmin"] = float(np.nanmin(data))
    if "vmax" not in option_kwargs:
        self.default_options["vmax"] = float(np.nanmax(data))
    self._vmin = self.default_options["vmin"]
    self._vmax = self.default_options["vmax"]

    # Compute ticks_spacing and write it to default_options for get_ticks().
    if "ticks_spacing" not in option_kwargs:
        spacing = (self._vmax - self._vmin) / 10
        self.default_options["ticks_spacing"] = max(spacing, 1e-10)
    self.ticks_spacing = self.default_options["ticks_spacing"]

    if title is not None:
        self.default_options["title"] = title

    if ax is not None:
        self.ax = ax
        self.fig = ax.get_figure()
    elif self.fig is None:
        self.fig, self.ax = self.create_figure_axes()

    ticks = self.get_ticks()
    norm, cbar_kw = self._create_norm_and_cbar_kw(ticks)

    tpc = self._render_mesh(
        self.ax,
        data,
        location,
        edgecolor=edgecolor,
        norm=norm,
        **render_kwargs,
    )

    # Remove previous colorbar before adding a new one.
    if self._cbar is not None:
        self._cbar.remove()
        self._cbar = None

    if colorbar:
        self._cbar = self.create_color_bar(self.ax, tpc, cbar_kw)

    if self.default_options["title"]:
        self.ax.set_title(
            self.default_options["title"],
            fontsize=self.default_options["title_size"],
        )
    self.ax.set_aspect("equal")

    return self.fig, self.ax

plot_outline(ax=None, color='black', linewidth=0.3, figsize=(10, 8), **kwargs) #

Plot mesh edges as a wireframe.

Uses matplotlib.collections.LineCollection for efficient rendering of thousands of edges.

Parameters:

Name Type Description Default
ax Any

Axes to plot on. If None, uses stored axes or creates new.

None
color str

Edge color. Default is "black".

'black'
linewidth float

Edge line width. Default is 0.3.

0.3
figsize tuple[int, int]

Figure size in inches. Default is (10, 8).

(10, 8)
**kwargs Any

Additional keyword arguments passed to LineCollection.

{}

Returns:

Type Description
tuple[Figure, Axes]

tuple[Figure, Axes]: The matplotlib Figure and Axes objects. When ax is None, a new figure is created. Call plt.close(fig) after saving to avoid memory leaks in batch processing.

Examples:

  • Render a triangular mesh wireframe:
    >>> import numpy as np
    >>> from cleopatra.mesh_glyph import MeshGlyph
    >>> mg = MeshGlyph(
    ...     np.array([0.0, 1.0, 0.5]),
    ...     np.array([0.0, 0.0, 1.0]),
    ...     np.array([[0, 1, 2]]),
    ... )
    >>> fig, ax = mg.plot_outline(color="blue")
    
Source code in src/cleopatra/mesh_glyph.py
def plot_outline(
    self,
    ax: Any = None,
    color: str = "black",
    linewidth: float = 0.3,
    figsize: tuple[int, int] = (10, 8),
    **kwargs: Any,
) -> tuple[plt.Figure, plt.Axes]:
    """Plot mesh edges as a wireframe.

    Uses ``matplotlib.collections.LineCollection`` for efficient
    rendering of thousands of edges.

    Args:
        ax: Axes to plot on. If None, uses stored axes or creates
            new.
        color: Edge color. Default is ``"black"``.
        linewidth: Edge line width. Default is ``0.3``.
        figsize: Figure size in inches. Default is ``(10, 8)``.
        **kwargs: Additional keyword arguments passed to
            ``LineCollection``.

    Returns:
        tuple[Figure, Axes]: The matplotlib Figure and Axes objects.
            When ``ax`` is None, a new figure is created. Call
            ``plt.close(fig)`` after saving to avoid memory leaks
            in batch processing.

    Examples:
        - Render a triangular mesh wireframe:
            ```python
            >>> import numpy as np
            >>> from cleopatra.mesh_glyph import MeshGlyph
            >>> mg = MeshGlyph(
            ...     np.array([0.0, 1.0, 0.5]),
            ...     np.array([0.0, 0.0, 1.0]),
            ...     np.array([[0, 1, 2]]),
            ... )
            >>> fig, ax = mg.plot_outline(color="blue")

            ```
    """
    if ax is not None:
        self.ax = ax
        self.fig = ax.get_figure()
    elif self.fig is None:
        self.fig, self.ax = plt.subplots(1, 1, figsize=figsize)

    segments = self._build_edge_segments()

    lc = mcoll.LineCollection(
        segments, colors=color, linewidths=linewidth, **kwargs
    )
    self.ax.add_collection(lc)
    self.ax.autoscale()
    self.ax.set_aspect("equal")

    return self.fig, self.ax

Examples#

Basic Face-Centered Plot#

import numpy as np
import matplotlib.tri as mtri
from cleopatra.mesh_glyph import MeshGlyph

# Create a triangular mesh from random points
rng = np.random.default_rng(42)
node_x = rng.uniform(0, 10, 50)
node_y = rng.uniform(0, 8, 50)
tri = mtri.Triangulation(node_x, node_y)

mg = MeshGlyph(node_x, node_y, tri.triangles)

# Synthetic face data
cx = node_x[tri.triangles].mean(axis=1)
cy = node_y[tri.triangles].mean(axis=1)
face_data = np.sin(cx * 0.5) * np.cos(cy * 0.4) + 2

fig, ax = mg.plot(face_data, cmap="RdYlBu_r", title="Face-Centered Data")

Node-Centered Contour Plot#

# Node data produces smooth interpolated contours
node_data = np.sin(node_x * 0.5) * np.cos(node_y * 0.4) * 3

fig, ax = mg.plot(
    node_data,
    location="node",
    cmap="terrain",
    levels=15,
    title="Node-Centered Contour",
)

Wireframe Outline#

# Render mesh edges as a wireframe
fig, ax = mg.plot_outline(color="steelblue", linewidth=0.5)

Overlay Data with Wireframe#

# Plot face data, then overlay wireframe on the same axes
mg2 = MeshGlyph(node_x, node_y, tri.triangles)
fig, ax = mg2.plot(face_data, cmap="Blues", title="Data + Wireframe")
mg2.plot_outline(color="black", linewidth=0.2)

Mixed-Element Mesh (Quads + Triangles)#

# Mixed meshes use fill_value=-1 for padding
node_x = np.array([0, 1, 2, 0, 1, 2], dtype=float)
node_y = np.array([0, 0, 0, 1, 1, 1], dtype=float)
faces = np.array([
    [0, 1, 4, 3],   # quad
    [1, 2, 5, -1],  # triangle (padded with -1)
    [1, 5, 4, -1],  # triangle
])

mg = MeshGlyph(node_x, node_y, faces, fill_value=-1)
fig, ax = mg.plot(np.array([1.0, 2.0, 3.0]), edgecolor="black")

Color Scales#

All 5 color scale types are supported via the color_scale keyword:

mg = MeshGlyph(node_x, node_y, faces, fill_value=-1)

# Power scale (emphasize low values)
fig, ax = mg.plot(data, color_scale="power", gamma=0.3)

# Symmetrical log scale
fig, ax = mg.plot(data, color_scale="sym-lognorm")

# Discrete boundary scale
fig, ax = mg.plot(data, color_scale="boundary-norm", bounds=[0, 2, 4, 6])

# Midpoint scale (split at a value)
fig, ax = mg.plot(data, color_scale="midpoint", midpoint=3.0, cmap="RdBu_r")

Colorbar Customization#

mg = MeshGlyph(node_x, node_y, faces, fill_value=-1)
fig, ax = mg.plot(
    data,
    cbar_label="Water Depth [m]",
    cbar_orientation="horizontal",
    cbar_length=0.6,
    cbar_label_size=14,
)

Animation#

# Animate time-varying face data on a fixed mesh
mg = MeshGlyph(node_x, node_y, tri.triangles)

# frames: (n_timesteps, n_faces) array
frames = np.array([face_data * (1 + 0.2 * t) for t in range(10)])
time_labels = [f"t={t}" for t in range(10)]

anim = mg.animate(frames, time=time_labels, cmap="plasma", interval=300)
mg.save_animation("mesh_animation.gif", fps=3)

Explicit Edge Connectivity#

When edge-node connectivity is available (e.g. from UGRID NetCDF files), pass it for faster wireframe rendering:

edges = np.array([[0, 1], [1, 2], [2, 3], [3, 0]])
mg = MeshGlyph(node_x, node_y, faces, edge_node_connectivity=edges)
fig, ax = mg.plot_outline()