Skip to content

TexturedGlobeGlyph Class#

The textured_globe_glyph module provides the TexturedGlobeGlyph class — cleopatra's one deliberate 3-D glyph. It wraps an equirectangular (lon/lat) RGB(A) texture onto a tilted sphere drawn on a matplotlib Axes3D, and can spin the globe frame-by-frame for animation.

It takes the same equirectangular layout as cleopatra.basemap.reference.relief() — an (H, W, 3) (or (H, W, 4)) array with row 0 at the north pole (+90°) and column 0 at −180° — so you can drape a relief raster (or any world texture) straight onto a globe. Like HistogramGlyph, it is a standalone class, not a Glyph subclass, because the base class's 2-D figure/colorbar pipeline does not apply to a sphere.

Resolution is the cost driver

matplotlib's 3-D surface is drawn on the CPU as one polygon per mesh face, so render time grows with n_lon × n_lat. The default 180 × 90 (~16k faces) renders a recognisable globe in ~1.5 s; 360 × 180 is ~7.7 s and 720 × 360 ~27 s. Raise the resolution for a sharper still, lower it for a smooth animation.

Class Documentation#

cleopatra.glyphs.globe.textured_globe_glyph.TexturedGlobeGlyph #

Wrap an equirectangular texture onto a tilted, spinnable 3-D sphere.

The glyph takes an equirectangular (plate-carree) (H, W, 3) or (H, W, 4) array -- rows running north (row 0, +90 deg) to south (last row, -90 deg), columns running west (col 0, -180 deg) to east (last col, +180 deg), exactly the layout of cleopatra.basemap.reference.relief() -- and paints it onto a unit sphere on a matplotlib Axes3D.

The polar axis is tilted tilt_deg from vertical, and draw(spin=...) rotates the sphere about that axis so the same instance can render a whole rotation without re-sampling the texture (see the module docstring).

The sphere can be lit from a direction: pass a sun unit vector (in world space) to shade a lambertian day/night terminator, with an ambient floor so the unlit side stays legible rather than going black. Lighting is applied per frame from the already-rotated vertices (one dot product), so the sample-once/rotate-per-frame design is kept -- the texture is never re-sampled and the facecolors cache is never mutated. sun=None (the default) renders evenly, byte-identical to an unlit globe; a fixed sun with a spinning globe sweeps the terminator across the surface as it turns.

Attributes:

Name Type Description
texture ndarray

The normalised RGBA texture, float in [0, 1], shape (H, W, 4).

tilt_deg float

Axial tilt of the polar axis from vertical, in degrees.

n_lon int

Number of longitude samples in the sphere mesh.

n_lat int

Number of latitude samples in the sphere mesh.

sampling str

How each face takes its colour from the texture ("point" or "area").

brightness float

Multiplier applied to the RGB channels (clipped to [0, 1]).

sun ndarray | None

The unit light direction in world space, or None for even lighting.

ambient float

The ambient floor (fraction) kept on the unlit side.

default_options dict

The resolved render options (figsize, elev, azim, background).

Methods:

Name Description
draw

Render the globe at a given spin angle.

animate

Return a FuncAnimation spinning the globe.

rotation_matrix

The (3, 3) body-to-world transform the glyph applies (tilt then spin).

transform

Push your own (N, 3) scene geometry through that same transform.

Notes

TexturedGlobeGlyph is a standalone class, not a Glyph subclass (like HistogramGlyph). The accepted option keys are exposed via the DEFAULT_OPTIONS class attribute and can be inspected/filtered with the option_keys and filter_kwargs classmethods.

Examples:

Build a globe from a small synthetic texture and render it:

>>> import numpy as np
>>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
>>> texture = np.zeros((16, 32, 3), dtype=np.uint8)
>>> texture[:8] = (40, 90, 180)
>>> globe = TexturedGlobeGlyph(texture, n_lon=48, n_lat=24)
>>> fig, ax = globe.draw(spin=45.0)
>>> ax.name
'3d'
>>> globe.surface is not None
True

Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
 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
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
class TexturedGlobeGlyph:
    """Wrap an equirectangular texture onto a tilted, spinnable 3-D sphere.

    The glyph takes an equirectangular (plate-carree) `(H, W, 3)` or `(H, W, 4)` array -- rows running north (row 0,
    +90 deg) to south (last row, -90 deg), columns running west (col 0, -180 deg) to east (last col, +180 deg), exactly
    the layout of `cleopatra.basemap.reference.relief()` -- and paints it onto a unit sphere on a matplotlib `Axes3D`.

    The polar axis is tilted `tilt_deg` from vertical, and `draw(spin=...)` rotates the sphere about that axis so the
    same instance can render a whole rotation without re-sampling the texture (see the module docstring).

    The sphere can be lit from a direction: pass a `sun` unit vector (in world space) to shade a lambertian day/night
    terminator, with an `ambient` floor so the unlit side stays legible rather than going black. Lighting is applied
    per frame from the already-rotated vertices (one dot product), so the sample-once/rotate-per-frame design is kept
    -- the texture is never re-sampled and the `facecolors` cache is never mutated. `sun=None` (the default) renders
    evenly, byte-identical to an unlit globe; a fixed `sun` with a spinning globe sweeps the terminator across the
    surface as it turns.

    Attributes:
        texture: The normalised RGBA texture, float in `[0, 1]`, shape `(H, W, 4)`.
        tilt_deg: Axial tilt of the polar axis from vertical, in degrees.
        n_lon: Number of longitude samples in the sphere mesh.
        n_lat: Number of latitude samples in the sphere mesh.
        sampling: How each face takes its colour from the texture (`"point"` or `"area"`).
        brightness: Multiplier applied to the RGB channels (clipped to `[0, 1]`).
        sun: The unit light direction in world space, or `None` for even lighting.
        ambient: The ambient floor (fraction) kept on the unlit side.
        default_options: The resolved render options (`figsize`, `elev`, `azim`, `background`).

    Methods:
        draw(ax=None, *, spin=0.0, sun=..., ambient=..., **kwargs): Render the globe at a given spin angle.
        animate(ax=None, n_frames=60, revolutions=1.0, sun=..., ...): Return a `FuncAnimation` spinning the globe.
        rotation_matrix(spin=0.0): The `(3, 3)` body-to-world transform the glyph applies (tilt then spin).
        transform(points, spin=0.0): Push your own `(N, 3)` scene geometry through that same transform.

    Notes:
        `TexturedGlobeGlyph` is a standalone class, not a `Glyph` subclass (like `HistogramGlyph`). The accepted option
        keys are exposed via the `DEFAULT_OPTIONS` class attribute and can be inspected/filtered with the `option_keys`
        and `filter_kwargs` classmethods.

    Examples:
        Build a globe from a small synthetic texture and render it:
        ```python
        >>> import numpy as np
        >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
        >>> texture = np.zeros((16, 32, 3), dtype=np.uint8)
        >>> texture[:8] = (40, 90, 180)
        >>> globe = TexturedGlobeGlyph(texture, n_lon=48, n_lat=24)
        >>> fig, ax = globe.draw(spin=45.0)
        >>> ax.name
        '3d'
        >>> globe.surface is not None
        True

        ```
    """

    #: Option keys this glyph accepts, exposed as a class attribute so they can be introspected/filtered before an
    #: instance exists (see `option_keys`/`filter_kwargs`).
    DEFAULT_OPTIONS = GLOBE_DEFAULT_OPTIONS

    def __init__(
        self,
        texture: np.ndarray,
        *,
        tilt_deg: float = EARTH_TILT_DEG,
        n_lon: int = 180,
        n_lat: int = 90,
        sampling: str = SAMPLING_POINT,
        brightness: float = 1.0,
        sun: tuple[float, float, float] | None = None,
        ambient: float = DEFAULT_AMBIENT,
        fig: Figure | None = None,
        ax: Axes3D | None = None,
        **kwargs,
    ):
        """Initialize the globe from an equirectangular texture.

        Args:
            texture: An equirectangular RGB(A) array of shape `(H, W, 3)` or `(H, W, 4)`, `H >= 2` and `W >= 2`.
                Integer arrays are divided by their dtype's maximum (uint8 -> 255, uint16 -> 65535); float arrays
                are assumed to be in `[0, 1]` and, only if a channel exceeds 1, are normalised by their own peak. A
                float `(H, W, 4)` array's alpha must already be in `[0, 1]`. NaN cells and negative values render
                black. Row 0 is the northern edge (+90 deg), the last row the southern edge (-90 deg); column 0 is
                -180 deg, the last column +180 deg.
            tilt_deg: Axial tilt of the polar axis from vertical, in degrees. Defaults to Earth's 23.44 deg.
            n_lon: Longitude samples in the sphere mesh (>= 2). Higher is sharper but quadratically slower to draw.
            n_lat: Latitude samples in the sphere mesh (>= 2). Higher is sharper but quadratically slower to draw.
            sampling: How each mesh face takes its colour from the texture. `"point"` (default) samples one
                texture cell at the face centre -- cheap, and the right choice for a photographic basemap where
                neighbouring cells are alike. `"area"` reduces the whole texture block a face covers,
                alpha-aware: a face's colour is the mean of the non-transparent cells (`alpha > 0`) it spans (or
                transparent if it spans none), so a feature narrower than one mesh face still paints instead of
                falling between sample points. A cell counts once regardless of its alpha and the RGB mean is not
                alpha-premultiplied, so a face over feathered/anti-aliased edges keeps their mean colour and a
                mean alpha (below 1). A face whose block holds no texture cell at all -- the mesh is finer than
                the texture there -- falls back to point sampling, so a coarse texture never gains gaps. Both
                modes keep the sample-once/rotate-per-frame contract -- the reduction runs once in `_prepare`.
                Defaults to `"point"`.
            brightness: Multiplier applied to the RGB channels before clipping to `[0, 1]`. `< 1` darkens, `> 1`
                brightens. Must be `>= 0`.
            sun: Light direction as a length-3 `(x, y, z)` vector in world space (the same frame the globe is drawn
                in: `+z` is up/north, `+x` toward the viewer at `spin=0`), or `None` (default) for even, unlit
                rendering. Need not be unit length -- it is normalised. Can be overridden per call in
                `draw`/`animate`.
            ambient: Floor brightness (fraction in `[0, 1]`) the unlit side keeps under directional lighting, so the
                night side is not pure black. Defaults to `0.13`. Always validated, but only affects the render
                when `sun` is set.
            fig: Pre-existing matplotlib `Figure` to draw on when `draw`/`animate` are called without their own `ax`.
            ax: Pre-existing 3-D matplotlib `Axes` (`Axes3D`) to draw on. If given it must be a 3-D axes.
            **kwargs: Render options overriding `DEFAULT_OPTIONS` (`figsize`, `elev`, `azim`, `background`). An
                unrecognised key raises `ValueError`.

        Raises:
            ValueError: If `texture` is not an `(H, W, 3)`/`(H, W, 4)` array with `H, W >= 2`, if `n_lon`/`n_lat` are
                `< 2`, if `sampling` is not `"point"` or `"area"`, if `brightness` is negative, if `sun` is not a
                length-3 finite non-zero vector (or `None`), if `ambient` is outside `[0, 1]`, if `ax` is given but
                is not a 3-D axes, or if an unknown render option is passed.

        Examples:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), n_lon=24, n_lat=12)
            >>> globe.n_lon, globe.n_lat
            (24, 12)

            ```
        """
        if int(n_lon) < 2 or int(n_lat) < 2:
            raise ValueError(
                f"n_lon and n_lat must each be >= 2; got n_lon={n_lon}, n_lat={n_lat}."
            )
        if sampling not in SAMPLING_MODES:
            raise ValueError(f'sampling must be "point" or "area"; got {sampling!r}.')
        if brightness < 0:
            raise ValueError(f"brightness must be >= 0; got {brightness}.")
        if ax is not None and not isinstance(ax, Axes3D):
            raise ValueError(
                "TexturedGlobeGlyph needs a 3-D axes; create one with fig.add_subplot(projection='3d')."
            )

        self._brightness = float(brightness)
        self._texture = self._normalize_texture(texture, self._brightness)
        self._tilt_deg = float(tilt_deg)
        self._n_lon = int(n_lon)
        self._n_lat = int(n_lat)
        self._sampling = sampling
        self._sun = self._normalize_sun(sun)
        self._ambient = self._validate_ambient(ambient)
        self._fig = fig
        self._ax = ax

        self._reject_unknown_options(kwargs)
        options_dict = GLOBE_DEFAULT_OPTIONS.copy()
        options_dict.update(kwargs)
        self._default_options = options_dict

        # Filled lazily and cached by `_prepare` (sample-once contract).
        self._base_xyz: np.ndarray | None = None
        self._facecolors: np.ndarray | None = None
        self._surface = None

    # ------------------------------------------------------------------ #
    # Construction helpers                                                 #
    # ------------------------------------------------------------------ #
    @staticmethod
    def _normalize_texture(texture: np.ndarray, brightness: float) -> np.ndarray:
        """Return `texture` as a float `(H, W, 4)` RGBA array in `[0, 1]`, brightness-scaled.

        Args:
            texture: An `(H, W, 3)` or `(H, W, 4)` RGB(A) array. Integer arrays are divided by their
                dtype's maximum (uint8 -> 255, uint16 -> 65535); float arrays are assumed to be in
                `[0, 1]` and, only if a channel exceeds 1, normalised by their own peak (NaN ignored).
                NaN cells render black.
            brightness: Multiplier applied to the RGB channels before clipping.

        Returns:
            numpy.ndarray: A contiguous float `(H, W, 4)` array in `[0, 1]`.

        Raises:
            ValueError: If `texture` is not an `(H, W, 3)`/`(H, W, 4)` array with `H, W >= 2`.
        """
        arr = np.asarray(texture)
        if (
            arr.ndim != 3
            or arr.shape[-1] not in (3, 4)
            or arr.shape[0] < 2
            or arr.shape[1] < 2
        ):
            raise ValueError(
                "texture must be an (H, W, 3) or (H, W, 4) array with H, W >= 2; "
                f"got shape {arr.shape}."
            )
        rgba = arr.astype(float)
        if np.issubdtype(arr.dtype, np.integer):
            # Integer textures span their dtype's full range (uint8 -> 255,
            # uint16 -> 65535, ...), so scale by that maximum rather than a
            # hard-coded 255 (alpha included).
            rgba = rgba / float(np.iinfo(arr.dtype).max)
        else:
            # Float textures are assumed to be in [0, 1]; if any channel exceeds 1 the
            # texture is normalised by its own peak so a stray highlight cannot black out
            # the globe. The peak is taken over the finite RGB values only, so a NaN cell
            # neither disables normalisation nor triggers an all-NaN warning.
            rgb = rgba[..., :3]
            finite_rgb = rgb[np.isfinite(rgb)]
            peak = finite_rgb.max() if finite_rgb.size else 0.0
            if peak > 1.0:
                # Scale only the RGB channels; alpha is already assumed to be in [0, 1].
                rgba[..., :3] = rgb / peak
        rgb = np.nan_to_num(np.clip(rgba[..., :3] * brightness, 0.0, 1.0), nan=0.0)
        if arr.shape[-1] == 4:
            alpha = np.nan_to_num(np.clip(rgba[..., 3:4], 0.0, 1.0), nan=1.0)
        else:
            alpha = np.ones((*rgb.shape[:2], 1))
        return np.ascontiguousarray(np.concatenate([rgb, alpha], axis=-1))

    def _prepare(self) -> None:
        """Sample the texture and build the base sphere mesh once, caching the results on the instance.

        Computes and caches the un-spun vertex coordinates `(3, n_lat * n_lon)` and the per-face `facecolors`
        `(n_lat - 1, n_lon - 1, 4)`, coloured per the `sampling` mode. Idempotent: repeated calls (e.g. one per
        animation frame) return immediately.
        """
        if self._base_xyz is not None:
            return

        lat_edges = np.linspace(90.0, -90.0, self._n_lat)
        lon_edges = np.linspace(-180.0, 180.0, self._n_lon)
        lon_grid, lat_grid = np.meshgrid(np.deg2rad(lon_edges), np.deg2rad(lat_edges))
        x = np.cos(lat_grid) * np.cos(lon_grid)
        y = np.cos(lat_grid) * np.sin(lon_grid)
        z = np.sin(lat_grid)
        self._base_xyz = np.stack([x.ravel(), y.ravel(), z.ravel()])

        # Colour each face from the texture -> (n_lat - 1, n_lon - 1, 4): a point
        # sample at the face centre, or (sampling="area") an alpha-aware reduction
        # of the whole texture block the face covers.
        self._facecolors = self._sample_facecolors(lat_edges, lon_edges)

    def _sample_facecolors(
        self, lat_edges: np.ndarray, lon_edges: np.ndarray
    ) -> np.ndarray:
        """Colour every mesh face from the texture, per the `sampling` mode.

        Args:
            lat_edges: The `(n_lat,)` latitude mesh edges, north (+90) to south (-90).
            lon_edges: The `(n_lon,)` longitude mesh edges, west (-180) to east (+180).

        Returns:
            numpy.ndarray: An `(n_lat - 1, n_lon - 1, 4)` float RGBA array in `[0, 1]`.
        """
        height, width = self._texture.shape[:2]
        # Point sample at each face centre. This is the "point" result outright, and
        # the fallback in "area" mode for faces finer than a single texture cell.
        lat_centers = 0.5 * (lat_edges[:-1] + lat_edges[1:])
        lon_centers = 0.5 * (lon_edges[:-1] + lon_edges[1:])
        rows = np.clip(
            np.round((90.0 - lat_centers) / 180.0 * (height - 1)).astype(int),
            0,
            height - 1,
        )
        cols = np.clip(
            np.round((lon_centers + 180.0) / 360.0 * (width - 1)).astype(int),
            0,
            width - 1,
        )
        row_idx, col_idx = np.meshgrid(rows, cols, indexing="ij")
        point_face = self._texture[row_idx, col_idx]
        if self._sampling == SAMPLING_POINT:
            return point_face
        return self._area_facecolors(point_face)

    def _area_facecolors(self, point_face: np.ndarray) -> np.ndarray:
        """Reduce the texture block each face covers, alpha-aware (the "area" mode).

        The texture pixel range is tiled into one contiguous block per face. A face's
        colour is the mean of the *non-transparent* cells (`alpha > 0`) in its block (so
        a small feature is kept visible rather than faded toward transparent by the empty
        cells around it); a block with cells but none painted renders transparent. Each
        cell counts once regardless of its alpha and the RGB mean is un-premultiplied, so
        a face over feathered edges keeps their mean colour and a mean alpha below 1.
        Faces whose block holds no texture cell at all -- the mesh is finer than the
        texture there -- fall back to the point sample, so a coarse texture never gains gaps.

        Args:
            point_face: The `(n_lat - 1, n_lon - 1, 4)` point-sampled colours, used as
                the fallback for faces finer than one texture cell.

        Returns:
            numpy.ndarray: An `(n_lat - 1, n_lon - 1, 4)` float RGBA array in `[0, 1]`.
        """
        height, width = self._texture.shape[:2]
        # Integer block boundaries tiling [0, H] x [0, W] with no gaps or overlaps;
        # face (i, j) spans rows [row_bounds[i], row_bounds[i+1]) x the col analog.
        row_bounds = np.round(np.linspace(0, height, self._n_lat)).astype(int)
        col_bounds = np.round(np.linspace(0, width, self._n_lon)).astype(int)
        # np.add.reduceat needs start indices in [0, len); the trailing block runs to
        # the array end, which equals H/W by construction, so the tiling stays exact.
        row_starts = np.clip(row_bounds[:-1], 0, height - 1)
        col_starts = np.clip(col_bounds[:-1], 0, width - 1)
        # Exact per-face cell count from the block sizes (reduceat mis-handles empty
        # blocks, so this is derived from the bounds, not from a reduction).
        cell_count = np.diff(row_bounds)[:, None] * np.diff(col_bounds)[None, :]

        # A cell contributes to the reduction when it is non-transparent (alpha > 0).
        nontransparent = self._texture[..., 3] > 0.0
        masked = self._texture * nontransparent[..., None]
        nontransparent_sum = np.add.reduceat(
            np.add.reduceat(masked, row_starts, axis=0), col_starts, axis=1
        )
        nontransparent_count = np.add.reduceat(
            np.add.reduceat(nontransparent.astype(float), row_starts, axis=0),
            col_starts,
            axis=1,
        )

        # Start from the point sample so empty-block faces keep it; overwrite every
        # face whose block has texture cells with the alpha-aware reduction (mean of
        # the non-transparent cells, or fully transparent when the block has none).
        face = point_face.copy()
        has_cells = cell_count > 0
        reduced = np.zeros_like(face)
        has_paint = nontransparent_count > 0
        reduced[has_paint] = (
            nontransparent_sum[has_paint] / nontransparent_count[has_paint][:, None]
        )
        face[has_cells] = reduced[has_cells]
        return face

    @staticmethod
    def _rotation_x(deg: float) -> np.ndarray:
        """Return the 3x3 matrix rotating a point cloud by `deg` degrees about the x-axis."""
        rad = np.deg2rad(deg)
        cos, sin = np.cos(rad), np.sin(rad)
        return np.array([[1.0, 0.0, 0.0], [0.0, cos, -sin], [0.0, sin, cos]])

    @staticmethod
    def _rotation_z(deg: float) -> np.ndarray:
        """Return the 3x3 matrix rotating a point cloud by `deg` degrees about the z-axis (the polar axis)."""
        rad = np.deg2rad(deg)
        cos, sin = np.cos(rad), np.sin(rad)
        return np.array([[cos, -sin, 0.0], [sin, cos, 0.0], [0.0, 0.0, 1.0]])

    def _spun_mesh(self, spin: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Return the `(x, y, z)` sphere mesh spun `spin` degrees about its tilted polar axis.

        The globe is rotated about its own polar axis (`z` in the body frame) and then the fixed axial tilt is applied,
        so the tilt stays put in space while the surface turns under it. Only the pre-computed base vertices are
        rotated -- the `facecolors` are untouched.

        Args:
            spin: Rotation about the polar axis, in degrees.

        Returns:
            tuple: Three `(n_lat, n_lon)` arrays `(x, y, z)` for `Axes3D.plot_surface`.
        """
        coords = self.rotation_matrix(spin) @ self._base_xyz
        return tuple(coords.reshape(3, self._n_lat, self._n_lon))

    def rotation_matrix(self, spin: float = 0.0) -> np.ndarray:
        """Return the 3x3 body-to-world rotation the glyph applies at a given spin.

        This is the exact transform `draw(spin=...)` uses to place the sphere: a rotation of `spin` degrees about
        the body polar axis (`z`), then the fixed axial tilt of `tilt_deg` about the world `x` axis --
        `R_tilt(x) @ R_z(spin)`. Apply it (or `transform`) to your own scene geometry so it sits consistently with
        the rendered globe without reimplementing the tilt.

        Args:
            spin: Rotation about the polar axis, in degrees (matching `draw`/`animate`'s `spin`).

        Returns:
            numpy.ndarray: A `(3, 3)` matrix `M` such that a body-frame column vector `p` maps to world as `M @ p`.

        Examples:
            - Identity at `spin=0` with no tilt:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
                >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), tilt_deg=0.0)
                >>> np.allclose(globe.rotation_matrix(0.0), np.eye(3))
                True

                ```

        See Also:
            transform: Apply this matrix to an `(N, 3)` array of points.
        """
        return np.asarray(self._rotation_x(self._tilt_deg) @ self._rotation_z(spin))

    def transform(self, points: npt.ArrayLike, spin: float = 0.0) -> np.ndarray:
        """Map body-frame point(s) into world space exactly as the glyph places its mesh.

        Pushes points through `rotation_matrix(spin)` (spin about the polar axis, then the axial tilt). The body
        frame is the same one the mesh is built in: a unit sphere with `+z` at the north pole, so a surface point at
        `(lon, lat)` is `[cos(lat) cos(lon), cos(lat) sin(lon), sin(lat)]`, the equatorial plane is `z = 0`, and the
        polar axis is `+z`. Use it to place an eclipse marker, a geostationary ring, or an orbit plane so they align
        with the rendered globe.

        Args:
            points: A single `(3,)` point or an `(N, 3)` array of body-frame points (any array-like). Non-finite
                values (`NaN`/`inf`) are propagated, not rejected (`inf` also emits a numpy `RuntimeWarning`) --
                pass finite coordinates.
            spin: Rotation about the polar axis, in degrees (matching `draw`/`animate`'s `spin`).

        Returns:
            numpy.ndarray: The transformed point(s), same shape as `points` (`(3,)` or `(N, 3)`).

        Raises:
            ValueError: If `points` is not `(3,)` or `(N, 3)`.

        Examples:
            - The north pole maps to the tilted axis; a 90 deg tilt lays it onto `-y`:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
                >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), tilt_deg=90.0)
                >>> np.round(globe.transform([0.0, 0.0, 1.0]), 6)
                array([ 0., -1.,  0.])

                ```

        See Also:
            rotation_matrix: The `(3, 3)` matrix this method applies.
        """
        pts = np.asarray(points, dtype=float)
        if pts.ndim not in (1, 2) or pts.shape[-1] != 3:
            raise ValueError(
                f"points must be a (3,) point or an (N, 3) array; got shape {pts.shape}."
            )
        result = np.atleast_2d(pts) @ self.rotation_matrix(spin).T
        if pts.ndim == 1:
            result = result[0]
        return np.asarray(result)

    @staticmethod
    def _normalize_sun(sun: tuple[float, float, float] | None) -> np.ndarray | None:
        """Validate a light direction and return it as a unit vector (or `None`).

        Args:
            sun: A length-3 `(x, y, z)` direction in world space, or `None` for even lighting. Need not be
                unit length -- it is normalised here.

        Returns:
            numpy.ndarray | None: The unit-length light direction, or `None` when `sun` is `None`.

        Raises:
            ValueError: If `sun` is not a length-3 finite vector, or is the zero vector.
        """
        if sun is None:
            return None
        vec = np.asarray(sun, dtype=float)
        if vec.shape != (3,) or not np.all(np.isfinite(vec)):
            raise ValueError(
                f"sun must be a 1-D length-3 finite (x, y, z) vector or None; got {sun!r}."
            )
        norm = float(np.sqrt(vec @ vec))
        if norm <= 0.0:
            raise ValueError("sun must be a non-zero direction vector; got (0, 0, 0).")
        return vec / norm

    @staticmethod
    def _validate_ambient(ambient: float) -> float:
        """Return `ambient` as a float in `[0, 1]`, raising `ValueError` otherwise."""
        amb = float(ambient)
        if not 0.0 <= amb <= 1.0:
            raise ValueError(f"ambient must be in [0, 1]; got {ambient}.")
        return amb

    def _lit_facecolors(
        self,
        spun_xyz: tuple[np.ndarray, np.ndarray, np.ndarray],
        sun: np.ndarray | None,
        ambient: float,
    ) -> np.ndarray:
        """Return the per-face colours scaled by a lambertian directional-light term.

        With `sun is None` the cached `facecolors` are returned unchanged (byte-identical to an unlit render). With a
        light direction, each face is dimmed toward `ambient` on the side facing away from the light, giving a
        day/night terminator. The cache itself is never mutated -- a scaled copy is returned -- so the
        sample-once/rotate-per-frame contract holds: only a dot product over the already-rotated vertices is done per
        frame, no texture re-sampling.

        Args:
            spun_xyz: The `(x, y, z)` unit-sphere vertex arrays from `_spun_mesh(spin)`; for a unit sphere each
                vertex position is its outward normal.
            sun: A unit light direction in world space, or `None` for even lighting.
            ambient: The floor brightness (fraction) kept on the unlit side.

        Returns:
            numpy.ndarray: An `(n_lat - 1, n_lon - 1, 4)` facecolors array.
        """
        assert self._facecolors is not None  # populated by _prepare() before any draw
        if sun is None:
            return self._facecolors
        x, y, z = spun_xyz
        # Per-face outward normal = mean of the quad's four corner vertices, renormalised.
        nx = 0.25 * (x[:-1, :-1] + x[1:, :-1] + x[:-1, 1:] + x[1:, 1:])
        ny = 0.25 * (y[:-1, :-1] + y[1:, :-1] + y[:-1, 1:] + y[1:, 1:])
        nz = 0.25 * (z[:-1, :-1] + z[1:, :-1] + z[:-1, 1:] + z[1:, 1:])
        # Floor the magnitude at a tiny epsilon to avoid division by zero on a
        # degenerate quad (never hit on real meshes; min magnitude is ~1).
        norm = np.maximum(np.sqrt(nx * nx + ny * ny + nz * nz), 1e-12)
        lit = np.clip((nx * sun[0] + ny * sun[1] + nz * sun[2]) / norm, 0.0, 1.0)
        factor = ambient + (1.0 - ambient) * lit
        lit_face = self._facecolors.copy()
        lit_face[..., :3] = np.clip(
            self._facecolors[..., :3] * factor[..., None], 0.0, 1.0
        )
        return lit_face

    def _resolve_axes(self, ax: Axes3D | None, options: dict) -> tuple[Figure, Axes3D]:
        """Resolve the 3-D `(fig, ax)` to draw on, creating a 3-D axes if none was supplied.

        Args:
            ax: An explicit 3-D axes for this call, or `None` to fall back to the instance's `ax`/`fig` or a new one.
            options: The resolved render options (uses `figsize` when a new figure is made).

        Returns:
            tuple: `(fig, ax)` where `ax` is an `Axes3D`.

        Raises:
            ValueError: If a supplied axes is not a 3-D axes.
        """
        target = ax if ax is not None else self._ax
        if target is None:
            fig = self._fig or plt.figure(figsize=options["figsize"])
            target = fig.add_subplot(projection="3d")
        else:
            if not isinstance(target, Axes3D):
                raise ValueError(
                    "TexturedGlobeGlyph needs a 3-D axes; create one with fig.add_subplot(projection='3d')."
                )
            fig = _root_figure(target)
        return fig, target

    # ------------------------------------------------------------------ #
    # Introspection (mirrors Glyph / HistogramGlyph)                      #
    # ------------------------------------------------------------------ #
    @property
    def texture(self) -> np.ndarray:
        """The normalised RGBA texture (float `(H, W, 4)` in `[0, 1]`)."""
        return self._texture

    @property
    def tilt_deg(self) -> float:
        """Axial tilt of the polar axis from vertical, in degrees."""
        return self._tilt_deg

    @property
    def n_lon(self) -> int:
        """Number of longitude samples in the sphere mesh."""
        return self._n_lon

    @property
    def n_lat(self) -> int:
        """Number of latitude samples in the sphere mesh."""
        return self._n_lat

    @property
    def sampling(self) -> str:
        """How each face takes its colour from the texture (`"point"` or `"area"`)."""
        return self._sampling

    @property
    def brightness(self) -> float:
        """The brightness multiplier applied to the RGB channels."""
        return self._brightness

    @property
    def sun(self) -> np.ndarray | None:
        """The unit light direction in world space, or `None` for even (unlit) rendering."""
        return self._sun

    @property
    def ambient(self) -> float:
        """The ambient floor (fraction) kept on the unlit side under directional lighting."""
        return self._ambient

    @property
    def default_options(self) -> dict:
        """The resolved render options (`figsize`, `elev`, `azim`, `background`)."""
        return self._default_options

    @property
    def face_colors(self) -> np.ndarray:
        """The base per-face RGBA colours the mesh is filled with, `(n_lat - 1, n_lon - 1, 4)` float in `[0, 1]`.

        These are the sampled colours before any per-frame directional lighting (which only scales RGB and
        never touches alpha), so a caller can check whether its data survived the texture sampling -- e.g. that a
        small feature still lands on at least one face -- without a `draw()` and without reaching into
        private state. Reading it samples the texture once (the sample-once contract); a copy is returned,
        so mutating it never disturbs the glyph's cache.

        Examples:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> texture = np.zeros((90, 180, 4), dtype=np.uint8)
            >>> texture[10:14, 20:24] = (255, 0, 0, 255)  # a small opaque patch
            >>> globe = TexturedGlobeGlyph(texture, n_lon=90, n_lat=45, sampling="area")
            >>> painted = globe.face_colors
            >>> painted.shape
            (44, 89, 4)
            >>> int((painted[..., 3] > 0).sum()) > 0  # the patch survived
            True

            ```
        """
        self._prepare()
        assert self._facecolors is not None  # populated by _prepare()
        return self._facecolors.copy()

    @classmethod
    def option_keys(cls) -> set[str]:
        """Return the keyword-argument keys this glyph accepts.

        Resolves from the class-level `DEFAULT_OPTIONS` so the accepted keys can be inspected without constructing an
        instance. Mirrors `cleopatra.glyphs.base.glyph.Glyph.option_keys` (`TexturedGlobeGlyph` is a standalone class,
        not a `Glyph` subclass).

        Returns:
            set: The accepted option keys for this glyph class.

        Examples:
            ```python
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> "elev" in TexturedGlobeGlyph.option_keys()
            True

            ```
        """
        return set(cls.DEFAULT_OPTIONS)

    @classmethod
    def filter_kwargs(cls, kwargs: dict) -> dict:
        """Return only the subset of `kwargs` whose keys this glyph accepts.

        Args:
            kwargs: A mapping of candidate option keys to values.

        Returns:
            dict: The entries of `kwargs` whose keys are in `option_keys()`.

        Examples:
            ```python
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> sorted(TexturedGlobeGlyph.filter_kwargs({"elev": 30, "bogus": 1}))
            ['elev']

            ```
        """
        keys = cls.option_keys()
        return {key: val for key, val in kwargs.items() if key in keys}

    @classmethod
    def _reject_unknown_options(cls, kwargs: dict) -> None:
        """Raise `ValueError` if `kwargs` holds keys this glyph does not accept.

        Mirrors the rest of the package (`Glyph._merge_kwargs`,
        `HistogramGlyph._apply_options`), which reject unknown options rather than
        silently ignoring them, so a typo like `elevv=` surfaces immediately.

        Args:
            kwargs: The render options passed to `__init__`/`draw`/`animate`.

        Raises:
            ValueError: If any key is not in `option_keys()`.
        """
        unknown = set(kwargs) - cls.option_keys()
        if unknown:
            raise ValueError(
                f"Unknown option(s) {sorted(unknown)}; accepted keys are "
                f"{sorted(cls.option_keys())}."
            )

    # ------------------------------------------------------------------ #
    # Rendering                                                            #
    # ------------------------------------------------------------------ #
    def draw(
        self,
        ax: Axes3D | None = None,
        *,
        spin: float = 0.0,
        sun: tuple[float, float, float] | None = _INHERIT,
        ambient: float = _INHERIT,
        **kwargs,
    ) -> tuple[Figure, Axes3D]:
        """Render the textured globe onto a 3-D axes at a given spin angle.

        Args:
            ax: A 3-D matplotlib axes (`Axes3D`) to draw on. If `None`, the instance's `ax` is used, or a new 3-D axes
                is created on the instance's `fig` (or a new figure sized by the `figsize` option).
            spin: Rotation of the globe about its tilted polar axis, in degrees. The camera stays put.
            sun: Light direction for this call, overriding the instance's `sun`. A length-3 world-space vector, or
                `None` to render evenly. Omitted (default) inherits the value passed to `__init__`.
            ambient: Ambient floor for this call, overriding the instance's `ambient`. Omitted inherits the
                constructor value. Always validated, but only affects the render when the effective `sun` is set.
            **kwargs: Render options overriding the instance defaults for this call (`figsize`, `elev`, `azim`,
                `background`). `figsize` applies only when a new figure is created; it is ignored when drawing onto
                an existing (supplied or instance) axes. An unrecognised key raises `ValueError`.

        Returns:
            tuple: `(fig, ax)` -- the figure and the 3-D axes the globe was drawn on. The surface artist is also
                available as the `surface` attribute.

        Raises:
            ValueError: If `ax` is supplied but is not a 3-D axes, if `sun`/`ambient` are invalid, or if an unknown
                render option is passed.

        Examples:
            - Render a spun frame:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
                >>> texture = np.zeros((16, 32, 3), dtype=np.uint8)
                >>> texture[:, :16] = (200, 40, 40)
                >>> fig, ax = TexturedGlobeGlyph(texture, n_lon=36, n_lat=18).draw(spin=90.0)
                >>> ax.name
                '3d'

                ```
            - Light it from the `+x` direction for a day/night terminator:
                ```python
                >>> globe = TexturedGlobeGlyph(np.full((16, 32, 3), 200, np.uint8), n_lon=36, n_lat=18)
                >>> fig, ax = globe.draw(sun=(1.0, 0.0, 0.0), ambient=0.15)
                >>> ax.name
                '3d'

                ```
        """
        self._reject_unknown_options(kwargs)
        options = self._default_options.copy()
        options.update(kwargs)
        fig, target = self._resolve_axes(ax, options)
        self._prepare()
        _clear_prior_render_artists(target, self)

        if options["background"] is not None:
            fig.set_facecolor(options["background"])
            target.set_facecolor(options["background"])

        sun_vec = self._sun if sun is _INHERIT else self._normalize_sun(sun)
        amb = self._ambient if ambient is _INHERIT else self._validate_ambient(ambient)
        x, y, z = self._spun_mesh(spin)
        surface = target.plot_surface(
            x,
            y,
            z,
            facecolors=self._lit_facecolors((x, y, z), sun_vec, amb),
            rstride=1,
            cstride=1,
            shade=False,
            antialiased=False,
            linewidth=0,
        )
        target.set_box_aspect((1, 1, 1))
        target.set_xlim(-1, 1)
        target.set_ylim(-1, 1)
        target.set_zlim(-1, 1)
        target.set_axis_off()
        target.view_init(elev=options["elev"], azim=options["azim"])

        _mark_render_artists(target, self, surface)
        self._surface = surface
        return fig, target

    def animate(
        self,
        ax: Axes3D | None = None,
        *,
        n_frames: int = 60,
        revolutions: float = 1.0,
        start_spin: float = 0.0,
        sun: tuple[float, float, float] | None = _INHERIT,
        ambient: float = _INHERIT,
        interval: int = 50,
        **kwargs,
    ) -> FuncAnimation:
        """Return a `FuncAnimation` that spins the globe about its polar axis.

        The texture is sampled once (via `draw`'s cached `_prepare`); each frame only rotates the pre-computed vertices
        and re-draws, so the per-frame cost is dominated by matplotlib's surface draw at the chosen mesh resolution.
        Save it with `cleopatra.glyphs.base.animation.save_animation` (to a file) or `to_gif`/`to_mp4` (to bytes),
        or matplotlib's own writers.

        Args:
            ax: A 3-D axes to animate on, or `None` to create one (see `draw`).
            n_frames: Number of frames in the animation.
            revolutions: How many full turns the globe makes over `n_frames` (`1.0` = one 360 deg rotation).
            start_spin: Spin angle of the first frame, in degrees.
            sun: Light direction forwarded to `draw` on every frame (world space; overrides the instance's `sun`).
                Held fixed while the globe spins, so the terminator sweeps across the surface. Omitted inherits the
                constructor value; `None` renders evenly.
            ambient: Ambient floor forwarded to `draw` on every frame. Omitted inherits the constructor value.
            interval: Delay between frames in milliseconds (matplotlib playback hint).
            **kwargs: Render options forwarded to `draw` on every frame (`figsize`, `elev`, `azim`, `background`).

        Returns:
            matplotlib.animation.FuncAnimation: The animation, ready to save or embed.

        Raises:
            ValueError: If `ax` is supplied but is not a 3-D axes, if `sun`/`ambient` are invalid, or if an unknown
                render option is passed. `sun`/`ambient` are validated eagerly here (not deferred to frame render).

        Examples:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), n_lon=24, n_lat=12)
            >>> anim = globe.animate(n_frames=4)
            >>> list(anim.new_frame_seq())    # one entry per rendered frame
            [0, 1, 2, 3]

            ```
        """
        self._reject_unknown_options(kwargs)
        # Validate lighting eagerly (like draw), so a bad sun/ambient raises here at the
        # animate() call rather than later from inside matplotlib's per-frame render loop.
        if sun is not _INHERIT:
            self._normalize_sun(sun)
        if ambient is not _INHERIT:
            self._validate_ambient(ambient)
        options = self._default_options.copy()
        options.update(kwargs)
        fig, target = self._resolve_axes(ax, options)
        self._prepare()
        angles = start_spin + np.linspace(
            0.0, 360.0 * revolutions, n_frames, endpoint=False
        )

        def _update(frame_index: int):
            self.draw(
                target,
                spin=float(angles[frame_index]),
                sun=sun,
                ambient=ambient,
                **kwargs,
            )
            return (self._surface,)

        return FuncAnimation(
            fig, _update, frames=n_frames, interval=interval, blit=False
        )

    @property
    def surface(self):
        """The `Poly3DCollection` from the most recent `draw`, or `None` before the first draw."""
        return self._surface

ambient property #

The ambient floor (fraction) kept on the unlit side under directional lighting.

brightness property #

The brightness multiplier applied to the RGB channels.

default_options property #

The resolved render options (figsize, elev, azim, background).

face_colors property #

The base per-face RGBA colours the mesh is filled with, (n_lat - 1, n_lon - 1, 4) float in [0, 1].

These are the sampled colours before any per-frame directional lighting (which only scales RGB and never touches alpha), so a caller can check whether its data survived the texture sampling -- e.g. that a small feature still lands on at least one face -- without a draw() and without reaching into private state. Reading it samples the texture once (the sample-once contract); a copy is returned, so mutating it never disturbs the glyph's cache.

Examples:

>>> import numpy as np
>>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
>>> texture = np.zeros((90, 180, 4), dtype=np.uint8)
>>> texture[10:14, 20:24] = (255, 0, 0, 255)  # a small opaque patch
>>> globe = TexturedGlobeGlyph(texture, n_lon=90, n_lat=45, sampling="area")
>>> painted = globe.face_colors
>>> painted.shape
(44, 89, 4)
>>> int((painted[..., 3] > 0).sum()) > 0  # the patch survived
True

n_lat property #

Number of latitude samples in the sphere mesh.

n_lon property #

Number of longitude samples in the sphere mesh.

sampling property #

How each face takes its colour from the texture ("point" or "area").

sun property #

The unit light direction in world space, or None for even (unlit) rendering.

surface property #

The Poly3DCollection from the most recent draw, or None before the first draw.

texture property #

The normalised RGBA texture (float (H, W, 4) in [0, 1]).

tilt_deg property #

Axial tilt of the polar axis from vertical, in degrees.

__init__(texture, *, tilt_deg=EARTH_TILT_DEG, n_lon=180, n_lat=90, sampling=SAMPLING_POINT, brightness=1.0, sun=None, ambient=DEFAULT_AMBIENT, fig=None, ax=None, **kwargs) #

Initialize the globe from an equirectangular texture.

Parameters:

Name Type Description Default
texture ndarray

An equirectangular RGB(A) array of shape (H, W, 3) or (H, W, 4), H >= 2 and W >= 2. Integer arrays are divided by their dtype's maximum (uint8 -> 255, uint16 -> 65535); float arrays are assumed to be in [0, 1] and, only if a channel exceeds 1, are normalised by their own peak. A float (H, W, 4) array's alpha must already be in [0, 1]. NaN cells and negative values render black. Row 0 is the northern edge (+90 deg), the last row the southern edge (-90 deg); column 0 is -180 deg, the last column +180 deg.

required
tilt_deg float

Axial tilt of the polar axis from vertical, in degrees. Defaults to Earth's 23.44 deg.

EARTH_TILT_DEG
n_lon int

Longitude samples in the sphere mesh (>= 2). Higher is sharper but quadratically slower to draw.

180
n_lat int

Latitude samples in the sphere mesh (>= 2). Higher is sharper but quadratically slower to draw.

90
sampling str

How each mesh face takes its colour from the texture. "point" (default) samples one texture cell at the face centre -- cheap, and the right choice for a photographic basemap where neighbouring cells are alike. "area" reduces the whole texture block a face covers, alpha-aware: a face's colour is the mean of the non-transparent cells (alpha > 0) it spans (or transparent if it spans none), so a feature narrower than one mesh face still paints instead of falling between sample points. A cell counts once regardless of its alpha and the RGB mean is not alpha-premultiplied, so a face over feathered/anti-aliased edges keeps their mean colour and a mean alpha (below 1). A face whose block holds no texture cell at all -- the mesh is finer than the texture there -- falls back to point sampling, so a coarse texture never gains gaps. Both modes keep the sample-once/rotate-per-frame contract -- the reduction runs once in _prepare. Defaults to "point".

SAMPLING_POINT
brightness float

Multiplier applied to the RGB channels before clipping to [0, 1]. < 1 darkens, > 1 brightens. Must be >= 0.

1.0
sun tuple[float, float, float] | None

Light direction as a length-3 (x, y, z) vector in world space (the same frame the globe is drawn in: +z is up/north, +x toward the viewer at spin=0), or None (default) for even, unlit rendering. Need not be unit length -- it is normalised. Can be overridden per call in draw/animate.

None
ambient float

Floor brightness (fraction in [0, 1]) the unlit side keeps under directional lighting, so the night side is not pure black. Defaults to 0.13. Always validated, but only affects the render when sun is set.

DEFAULT_AMBIENT
fig Figure | None

Pre-existing matplotlib Figure to draw on when draw/animate are called without their own ax.

None
ax Axes3D | None

Pre-existing 3-D matplotlib Axes (Axes3D) to draw on. If given it must be a 3-D axes.

None
**kwargs

Render options overriding DEFAULT_OPTIONS (figsize, elev, azim, background). An unrecognised key raises ValueError.

{}

Raises:

Type Description
ValueError

If texture is not an (H, W, 3)/(H, W, 4) array with H, W >= 2, if n_lon/n_lat are < 2, if sampling is not "point" or "area", if brightness is negative, if sun is not a length-3 finite non-zero vector (or None), if ambient is outside [0, 1], if ax is given but is not a 3-D axes, or if an unknown render option is passed.

Examples:

>>> import numpy as np
>>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
>>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), n_lon=24, n_lat=12)
>>> globe.n_lon, globe.n_lat
(24, 12)
Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
def __init__(
    self,
    texture: np.ndarray,
    *,
    tilt_deg: float = EARTH_TILT_DEG,
    n_lon: int = 180,
    n_lat: int = 90,
    sampling: str = SAMPLING_POINT,
    brightness: float = 1.0,
    sun: tuple[float, float, float] | None = None,
    ambient: float = DEFAULT_AMBIENT,
    fig: Figure | None = None,
    ax: Axes3D | None = None,
    **kwargs,
):
    """Initialize the globe from an equirectangular texture.

    Args:
        texture: An equirectangular RGB(A) array of shape `(H, W, 3)` or `(H, W, 4)`, `H >= 2` and `W >= 2`.
            Integer arrays are divided by their dtype's maximum (uint8 -> 255, uint16 -> 65535); float arrays
            are assumed to be in `[0, 1]` and, only if a channel exceeds 1, are normalised by their own peak. A
            float `(H, W, 4)` array's alpha must already be in `[0, 1]`. NaN cells and negative values render
            black. Row 0 is the northern edge (+90 deg), the last row the southern edge (-90 deg); column 0 is
            -180 deg, the last column +180 deg.
        tilt_deg: Axial tilt of the polar axis from vertical, in degrees. Defaults to Earth's 23.44 deg.
        n_lon: Longitude samples in the sphere mesh (>= 2). Higher is sharper but quadratically slower to draw.
        n_lat: Latitude samples in the sphere mesh (>= 2). Higher is sharper but quadratically slower to draw.
        sampling: How each mesh face takes its colour from the texture. `"point"` (default) samples one
            texture cell at the face centre -- cheap, and the right choice for a photographic basemap where
            neighbouring cells are alike. `"area"` reduces the whole texture block a face covers,
            alpha-aware: a face's colour is the mean of the non-transparent cells (`alpha > 0`) it spans (or
            transparent if it spans none), so a feature narrower than one mesh face still paints instead of
            falling between sample points. A cell counts once regardless of its alpha and the RGB mean is not
            alpha-premultiplied, so a face over feathered/anti-aliased edges keeps their mean colour and a
            mean alpha (below 1). A face whose block holds no texture cell at all -- the mesh is finer than
            the texture there -- falls back to point sampling, so a coarse texture never gains gaps. Both
            modes keep the sample-once/rotate-per-frame contract -- the reduction runs once in `_prepare`.
            Defaults to `"point"`.
        brightness: Multiplier applied to the RGB channels before clipping to `[0, 1]`. `< 1` darkens, `> 1`
            brightens. Must be `>= 0`.
        sun: Light direction as a length-3 `(x, y, z)` vector in world space (the same frame the globe is drawn
            in: `+z` is up/north, `+x` toward the viewer at `spin=0`), or `None` (default) for even, unlit
            rendering. Need not be unit length -- it is normalised. Can be overridden per call in
            `draw`/`animate`.
        ambient: Floor brightness (fraction in `[0, 1]`) the unlit side keeps under directional lighting, so the
            night side is not pure black. Defaults to `0.13`. Always validated, but only affects the render
            when `sun` is set.
        fig: Pre-existing matplotlib `Figure` to draw on when `draw`/`animate` are called without their own `ax`.
        ax: Pre-existing 3-D matplotlib `Axes` (`Axes3D`) to draw on. If given it must be a 3-D axes.
        **kwargs: Render options overriding `DEFAULT_OPTIONS` (`figsize`, `elev`, `azim`, `background`). An
            unrecognised key raises `ValueError`.

    Raises:
        ValueError: If `texture` is not an `(H, W, 3)`/`(H, W, 4)` array with `H, W >= 2`, if `n_lon`/`n_lat` are
            `< 2`, if `sampling` is not `"point"` or `"area"`, if `brightness` is negative, if `sun` is not a
            length-3 finite non-zero vector (or `None`), if `ambient` is outside `[0, 1]`, if `ax` is given but
            is not a 3-D axes, or if an unknown render option is passed.

    Examples:
        ```python
        >>> import numpy as np
        >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
        >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), n_lon=24, n_lat=12)
        >>> globe.n_lon, globe.n_lat
        (24, 12)

        ```
    """
    if int(n_lon) < 2 or int(n_lat) < 2:
        raise ValueError(
            f"n_lon and n_lat must each be >= 2; got n_lon={n_lon}, n_lat={n_lat}."
        )
    if sampling not in SAMPLING_MODES:
        raise ValueError(f'sampling must be "point" or "area"; got {sampling!r}.')
    if brightness < 0:
        raise ValueError(f"brightness must be >= 0; got {brightness}.")
    if ax is not None and not isinstance(ax, Axes3D):
        raise ValueError(
            "TexturedGlobeGlyph needs a 3-D axes; create one with fig.add_subplot(projection='3d')."
        )

    self._brightness = float(brightness)
    self._texture = self._normalize_texture(texture, self._brightness)
    self._tilt_deg = float(tilt_deg)
    self._n_lon = int(n_lon)
    self._n_lat = int(n_lat)
    self._sampling = sampling
    self._sun = self._normalize_sun(sun)
    self._ambient = self._validate_ambient(ambient)
    self._fig = fig
    self._ax = ax

    self._reject_unknown_options(kwargs)
    options_dict = GLOBE_DEFAULT_OPTIONS.copy()
    options_dict.update(kwargs)
    self._default_options = options_dict

    # Filled lazily and cached by `_prepare` (sample-once contract).
    self._base_xyz: np.ndarray | None = None
    self._facecolors: np.ndarray | None = None
    self._surface = None

animate(ax=None, *, n_frames=60, revolutions=1.0, start_spin=0.0, sun=_INHERIT, ambient=_INHERIT, interval=50, **kwargs) #

Return a FuncAnimation that spins the globe about its polar axis.

The texture is sampled once (via draw's cached _prepare); each frame only rotates the pre-computed vertices and re-draws, so the per-frame cost is dominated by matplotlib's surface draw at the chosen mesh resolution. Save it with cleopatra.glyphs.base.animation.save_animation (to a file) or to_gif/to_mp4 (to bytes), or matplotlib's own writers.

Parameters:

Name Type Description Default
ax Axes3D | None

A 3-D axes to animate on, or None to create one (see draw).

None
n_frames int

Number of frames in the animation.

60
revolutions float

How many full turns the globe makes over n_frames (1.0 = one 360 deg rotation).

1.0
start_spin float

Spin angle of the first frame, in degrees.

0.0
sun tuple[float, float, float] | None

Light direction forwarded to draw on every frame (world space; overrides the instance's sun). Held fixed while the globe spins, so the terminator sweeps across the surface. Omitted inherits the constructor value; None renders evenly.

_INHERIT
ambient float

Ambient floor forwarded to draw on every frame. Omitted inherits the constructor value.

_INHERIT
interval int

Delay between frames in milliseconds (matplotlib playback hint).

50
**kwargs

Render options forwarded to draw on every frame (figsize, elev, azim, background).

{}

Returns:

Type Description
FuncAnimation

matplotlib.animation.FuncAnimation: The animation, ready to save or embed.

Raises:

Type Description
ValueError

If ax is supplied but is not a 3-D axes, if sun/ambient are invalid, or if an unknown render option is passed. sun/ambient are validated eagerly here (not deferred to frame render).

Examples:

>>> import numpy as np
>>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
>>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), n_lon=24, n_lat=12)
>>> anim = globe.animate(n_frames=4)
>>> list(anim.new_frame_seq())    # one entry per rendered frame
[0, 1, 2, 3]
Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
def animate(
    self,
    ax: Axes3D | None = None,
    *,
    n_frames: int = 60,
    revolutions: float = 1.0,
    start_spin: float = 0.0,
    sun: tuple[float, float, float] | None = _INHERIT,
    ambient: float = _INHERIT,
    interval: int = 50,
    **kwargs,
) -> FuncAnimation:
    """Return a `FuncAnimation` that spins the globe about its polar axis.

    The texture is sampled once (via `draw`'s cached `_prepare`); each frame only rotates the pre-computed vertices
    and re-draws, so the per-frame cost is dominated by matplotlib's surface draw at the chosen mesh resolution.
    Save it with `cleopatra.glyphs.base.animation.save_animation` (to a file) or `to_gif`/`to_mp4` (to bytes),
    or matplotlib's own writers.

    Args:
        ax: A 3-D axes to animate on, or `None` to create one (see `draw`).
        n_frames: Number of frames in the animation.
        revolutions: How many full turns the globe makes over `n_frames` (`1.0` = one 360 deg rotation).
        start_spin: Spin angle of the first frame, in degrees.
        sun: Light direction forwarded to `draw` on every frame (world space; overrides the instance's `sun`).
            Held fixed while the globe spins, so the terminator sweeps across the surface. Omitted inherits the
            constructor value; `None` renders evenly.
        ambient: Ambient floor forwarded to `draw` on every frame. Omitted inherits the constructor value.
        interval: Delay between frames in milliseconds (matplotlib playback hint).
        **kwargs: Render options forwarded to `draw` on every frame (`figsize`, `elev`, `azim`, `background`).

    Returns:
        matplotlib.animation.FuncAnimation: The animation, ready to save or embed.

    Raises:
        ValueError: If `ax` is supplied but is not a 3-D axes, if `sun`/`ambient` are invalid, or if an unknown
            render option is passed. `sun`/`ambient` are validated eagerly here (not deferred to frame render).

    Examples:
        ```python
        >>> import numpy as np
        >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
        >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), n_lon=24, n_lat=12)
        >>> anim = globe.animate(n_frames=4)
        >>> list(anim.new_frame_seq())    # one entry per rendered frame
        [0, 1, 2, 3]

        ```
    """
    self._reject_unknown_options(kwargs)
    # Validate lighting eagerly (like draw), so a bad sun/ambient raises here at the
    # animate() call rather than later from inside matplotlib's per-frame render loop.
    if sun is not _INHERIT:
        self._normalize_sun(sun)
    if ambient is not _INHERIT:
        self._validate_ambient(ambient)
    options = self._default_options.copy()
    options.update(kwargs)
    fig, target = self._resolve_axes(ax, options)
    self._prepare()
    angles = start_spin + np.linspace(
        0.0, 360.0 * revolutions, n_frames, endpoint=False
    )

    def _update(frame_index: int):
        self.draw(
            target,
            spin=float(angles[frame_index]),
            sun=sun,
            ambient=ambient,
            **kwargs,
        )
        return (self._surface,)

    return FuncAnimation(
        fig, _update, frames=n_frames, interval=interval, blit=False
    )

draw(ax=None, *, spin=0.0, sun=_INHERIT, ambient=_INHERIT, **kwargs) #

Render the textured globe onto a 3-D axes at a given spin angle.

Parameters:

Name Type Description Default
ax Axes3D | None

A 3-D matplotlib axes (Axes3D) to draw on. If None, the instance's ax is used, or a new 3-D axes is created on the instance's fig (or a new figure sized by the figsize option).

None
spin float

Rotation of the globe about its tilted polar axis, in degrees. The camera stays put.

0.0
sun tuple[float, float, float] | None

Light direction for this call, overriding the instance's sun. A length-3 world-space vector, or None to render evenly. Omitted (default) inherits the value passed to __init__.

_INHERIT
ambient float

Ambient floor for this call, overriding the instance's ambient. Omitted inherits the constructor value. Always validated, but only affects the render when the effective sun is set.

_INHERIT
**kwargs

Render options overriding the instance defaults for this call (figsize, elev, azim, background). figsize applies only when a new figure is created; it is ignored when drawing onto an existing (supplied or instance) axes. An unrecognised key raises ValueError.

{}

Returns:

Name Type Description
tuple tuple[Figure, Axes3D]

(fig, ax) -- the figure and the 3-D axes the globe was drawn on. The surface artist is also available as the surface attribute.

Raises:

Type Description
ValueError

If ax is supplied but is not a 3-D axes, if sun/ambient are invalid, or if an unknown render option is passed.

Examples:

  • Render a spun frame:
    >>> import numpy as np
    >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
    >>> texture = np.zeros((16, 32, 3), dtype=np.uint8)
    >>> texture[:, :16] = (200, 40, 40)
    >>> fig, ax = TexturedGlobeGlyph(texture, n_lon=36, n_lat=18).draw(spin=90.0)
    >>> ax.name
    '3d'
    
  • Light it from the +x direction for a day/night terminator:
    >>> globe = TexturedGlobeGlyph(np.full((16, 32, 3), 200, np.uint8), n_lon=36, n_lat=18)
    >>> fig, ax = globe.draw(sun=(1.0, 0.0, 0.0), ambient=0.15)
    >>> ax.name
    '3d'
    
Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
def draw(
    self,
    ax: Axes3D | None = None,
    *,
    spin: float = 0.0,
    sun: tuple[float, float, float] | None = _INHERIT,
    ambient: float = _INHERIT,
    **kwargs,
) -> tuple[Figure, Axes3D]:
    """Render the textured globe onto a 3-D axes at a given spin angle.

    Args:
        ax: A 3-D matplotlib axes (`Axes3D`) to draw on. If `None`, the instance's `ax` is used, or a new 3-D axes
            is created on the instance's `fig` (or a new figure sized by the `figsize` option).
        spin: Rotation of the globe about its tilted polar axis, in degrees. The camera stays put.
        sun: Light direction for this call, overriding the instance's `sun`. A length-3 world-space vector, or
            `None` to render evenly. Omitted (default) inherits the value passed to `__init__`.
        ambient: Ambient floor for this call, overriding the instance's `ambient`. Omitted inherits the
            constructor value. Always validated, but only affects the render when the effective `sun` is set.
        **kwargs: Render options overriding the instance defaults for this call (`figsize`, `elev`, `azim`,
            `background`). `figsize` applies only when a new figure is created; it is ignored when drawing onto
            an existing (supplied or instance) axes. An unrecognised key raises `ValueError`.

    Returns:
        tuple: `(fig, ax)` -- the figure and the 3-D axes the globe was drawn on. The surface artist is also
            available as the `surface` attribute.

    Raises:
        ValueError: If `ax` is supplied but is not a 3-D axes, if `sun`/`ambient` are invalid, or if an unknown
            render option is passed.

    Examples:
        - Render a spun frame:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> texture = np.zeros((16, 32, 3), dtype=np.uint8)
            >>> texture[:, :16] = (200, 40, 40)
            >>> fig, ax = TexturedGlobeGlyph(texture, n_lon=36, n_lat=18).draw(spin=90.0)
            >>> ax.name
            '3d'

            ```
        - Light it from the `+x` direction for a day/night terminator:
            ```python
            >>> globe = TexturedGlobeGlyph(np.full((16, 32, 3), 200, np.uint8), n_lon=36, n_lat=18)
            >>> fig, ax = globe.draw(sun=(1.0, 0.0, 0.0), ambient=0.15)
            >>> ax.name
            '3d'

            ```
    """
    self._reject_unknown_options(kwargs)
    options = self._default_options.copy()
    options.update(kwargs)
    fig, target = self._resolve_axes(ax, options)
    self._prepare()
    _clear_prior_render_artists(target, self)

    if options["background"] is not None:
        fig.set_facecolor(options["background"])
        target.set_facecolor(options["background"])

    sun_vec = self._sun if sun is _INHERIT else self._normalize_sun(sun)
    amb = self._ambient if ambient is _INHERIT else self._validate_ambient(ambient)
    x, y, z = self._spun_mesh(spin)
    surface = target.plot_surface(
        x,
        y,
        z,
        facecolors=self._lit_facecolors((x, y, z), sun_vec, amb),
        rstride=1,
        cstride=1,
        shade=False,
        antialiased=False,
        linewidth=0,
    )
    target.set_box_aspect((1, 1, 1))
    target.set_xlim(-1, 1)
    target.set_ylim(-1, 1)
    target.set_zlim(-1, 1)
    target.set_axis_off()
    target.view_init(elev=options["elev"], azim=options["azim"])

    _mark_render_artists(target, self, surface)
    self._surface = surface
    return fig, target

filter_kwargs(kwargs) classmethod #

Return only the subset of kwargs whose keys this glyph accepts.

Parameters:

Name Type Description Default
kwargs dict

A mapping of candidate option keys to values.

required

Returns:

Name Type Description
dict dict

The entries of kwargs whose keys are in option_keys().

Examples:

>>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
>>> sorted(TexturedGlobeGlyph.filter_kwargs({"elev": 30, "bogus": 1}))
['elev']
Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
@classmethod
def filter_kwargs(cls, kwargs: dict) -> dict:
    """Return only the subset of `kwargs` whose keys this glyph accepts.

    Args:
        kwargs: A mapping of candidate option keys to values.

    Returns:
        dict: The entries of `kwargs` whose keys are in `option_keys()`.

    Examples:
        ```python
        >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
        >>> sorted(TexturedGlobeGlyph.filter_kwargs({"elev": 30, "bogus": 1}))
        ['elev']

        ```
    """
    keys = cls.option_keys()
    return {key: val for key, val in kwargs.items() if key in keys}

option_keys() classmethod #

Return the keyword-argument keys this glyph accepts.

Resolves from the class-level DEFAULT_OPTIONS so the accepted keys can be inspected without constructing an instance. Mirrors cleopatra.glyphs.base.glyph.Glyph.option_keys (TexturedGlobeGlyph is a standalone class, not a Glyph subclass).

Returns:

Name Type Description
set set[str]

The accepted option keys for this glyph class.

Examples:

>>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
>>> "elev" in TexturedGlobeGlyph.option_keys()
True
Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
@classmethod
def option_keys(cls) -> set[str]:
    """Return the keyword-argument keys this glyph accepts.

    Resolves from the class-level `DEFAULT_OPTIONS` so the accepted keys can be inspected without constructing an
    instance. Mirrors `cleopatra.glyphs.base.glyph.Glyph.option_keys` (`TexturedGlobeGlyph` is a standalone class,
    not a `Glyph` subclass).

    Returns:
        set: The accepted option keys for this glyph class.

    Examples:
        ```python
        >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
        >>> "elev" in TexturedGlobeGlyph.option_keys()
        True

        ```
    """
    return set(cls.DEFAULT_OPTIONS)

rotation_matrix(spin=0.0) #

Return the 3x3 body-to-world rotation the glyph applies at a given spin.

This is the exact transform draw(spin=...) uses to place the sphere: a rotation of spin degrees about the body polar axis (z), then the fixed axial tilt of tilt_deg about the world x axis -- R_tilt(x) @ R_z(spin). Apply it (or transform) to your own scene geometry so it sits consistently with the rendered globe without reimplementing the tilt.

Parameters:

Name Type Description Default
spin float

Rotation about the polar axis, in degrees (matching draw/animate's spin).

0.0

Returns:

Type Description
ndarray

numpy.ndarray: A (3, 3) matrix M such that a body-frame column vector p maps to world as M @ p.

Examples:

  • Identity at spin=0 with no tilt:
    >>> import numpy as np
    >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
    >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), tilt_deg=0.0)
    >>> np.allclose(globe.rotation_matrix(0.0), np.eye(3))
    True
    
See Also

transform: Apply this matrix to an (N, 3) array of points.

Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
def rotation_matrix(self, spin: float = 0.0) -> np.ndarray:
    """Return the 3x3 body-to-world rotation the glyph applies at a given spin.

    This is the exact transform `draw(spin=...)` uses to place the sphere: a rotation of `spin` degrees about
    the body polar axis (`z`), then the fixed axial tilt of `tilt_deg` about the world `x` axis --
    `R_tilt(x) @ R_z(spin)`. Apply it (or `transform`) to your own scene geometry so it sits consistently with
    the rendered globe without reimplementing the tilt.

    Args:
        spin: Rotation about the polar axis, in degrees (matching `draw`/`animate`'s `spin`).

    Returns:
        numpy.ndarray: A `(3, 3)` matrix `M` such that a body-frame column vector `p` maps to world as `M @ p`.

    Examples:
        - Identity at `spin=0` with no tilt:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), tilt_deg=0.0)
            >>> np.allclose(globe.rotation_matrix(0.0), np.eye(3))
            True

            ```

    See Also:
        transform: Apply this matrix to an `(N, 3)` array of points.
    """
    return np.asarray(self._rotation_x(self._tilt_deg) @ self._rotation_z(spin))

transform(points, spin=0.0) #

Map body-frame point(s) into world space exactly as the glyph places its mesh.

Pushes points through rotation_matrix(spin) (spin about the polar axis, then the axial tilt). The body frame is the same one the mesh is built in: a unit sphere with +z at the north pole, so a surface point at (lon, lat) is [cos(lat) cos(lon), cos(lat) sin(lon), sin(lat)], the equatorial plane is z = 0, and the polar axis is +z. Use it to place an eclipse marker, a geostationary ring, or an orbit plane so they align with the rendered globe.

Parameters:

Name Type Description Default
points ArrayLike

A single (3,) point or an (N, 3) array of body-frame points (any array-like). Non-finite values (NaN/inf) are propagated, not rejected (inf also emits a numpy RuntimeWarning) -- pass finite coordinates.

required
spin float

Rotation about the polar axis, in degrees (matching draw/animate's spin).

0.0

Returns:

Type Description
ndarray

numpy.ndarray: The transformed point(s), same shape as points ((3,) or (N, 3)).

Raises:

Type Description
ValueError

If points is not (3,) or (N, 3).

Examples:

  • The north pole maps to the tilted axis; a 90 deg tilt lays it onto -y:
    >>> import numpy as np
    >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
    >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), tilt_deg=90.0)
    >>> np.round(globe.transform([0.0, 0.0, 1.0]), 6)
    array([ 0., -1.,  0.])
    
See Also

rotation_matrix: The (3, 3) matrix this method applies.

Source code in src/cleopatra/glyphs/globe/textured_globe_glyph.py
def transform(self, points: npt.ArrayLike, spin: float = 0.0) -> np.ndarray:
    """Map body-frame point(s) into world space exactly as the glyph places its mesh.

    Pushes points through `rotation_matrix(spin)` (spin about the polar axis, then the axial tilt). The body
    frame is the same one the mesh is built in: a unit sphere with `+z` at the north pole, so a surface point at
    `(lon, lat)` is `[cos(lat) cos(lon), cos(lat) sin(lon), sin(lat)]`, the equatorial plane is `z = 0`, and the
    polar axis is `+z`. Use it to place an eclipse marker, a geostationary ring, or an orbit plane so they align
    with the rendered globe.

    Args:
        points: A single `(3,)` point or an `(N, 3)` array of body-frame points (any array-like). Non-finite
            values (`NaN`/`inf`) are propagated, not rejected (`inf` also emits a numpy `RuntimeWarning`) --
            pass finite coordinates.
        spin: Rotation about the polar axis, in degrees (matching `draw`/`animate`'s `spin`).

    Returns:
        numpy.ndarray: The transformed point(s), same shape as `points` (`(3,)` or `(N, 3)`).

    Raises:
        ValueError: If `points` is not `(3,)` or `(N, 3)`.

    Examples:
        - The north pole maps to the tilted axis; a 90 deg tilt lays it onto `-y`:
            ```python
            >>> import numpy as np
            >>> from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph
            >>> globe = TexturedGlobeGlyph(np.zeros((8, 16, 3), dtype=np.uint8), tilt_deg=90.0)
            >>> np.round(globe.transform([0.0, 0.0, 1.0]), 6)
            array([ 0., -1.,  0.])

            ```

    See Also:
        rotation_matrix: The `(3, 3)` matrix this method applies.
    """
    pts = np.asarray(points, dtype=float)
    if pts.ndim not in (1, 2) or pts.shape[-1] != 3:
        raise ValueError(
            f"points must be a (3,) point or an (N, 3) array; got shape {pts.shape}."
        )
    result = np.atleast_2d(pts) @ self.rotation_matrix(spin).T
    if pts.ndim == 1:
        result = result[0]
    return np.asarray(result)

Examples#

A still globe from a relief texture#

import matplotlib.pyplot as plt
from cleopatra.basemap.reference import relief
from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph

texture = relief("low")            # (360, 720, 3) equirectangular RGB, north-up
globe = TexturedGlobeGlyph(texture, tilt_deg=23.44, brightness=1.1)
fig, ax = globe.draw(spin=60.0, elev=20, background="black")
plt.show()

A synthetic texture (no download)#

import numpy as np
import matplotlib.pyplot as plt
from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph

texture = np.zeros((180, 360, 3), dtype=np.uint8)
texture[:90] = (40, 90, 180)       # northern hemisphere blue
texture[90:] = (180, 120, 40)      # southern hemisphere ochre

fig, ax = TexturedGlobeGlyph(texture).draw(spin=45.0)
plt.show()

A day/night terminator (directional lighting)#

Pass a sun unit vector (in world space: +z is north/up, +x faces the viewer at spin=0) to light the sphere from a direction. ambient is a floor so the night side stays legible. Lighting is applied per frame from the already-rotated vertices — no texture re-sampling — so a fixed sun with a spinning globe sweeps the terminator across the surface. sun=None (the default) renders evenly.

Note

This is the 3-D globe's directional lighting (shading on a sphere). For a day/night terminator on an ordinary flat (lon/lat) axes, use cleopatra.basemap.solaradd_nightshade draws the terminator and night region as lon/lat geometry rather than shading a sphere.

from cleopatra.basemap.reference import relief
from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph

globe = TexturedGlobeGlyph(relief("low"), sun=(0.0, 1.0, 0.3), ambient=0.13)
fig, ax = globe.draw(spin=40.0, background="black")   # side-lit: one half in daylight, the other in night

A spinning animation#

The texture is sampled once; each frame only rotates the pre-computed mesh, so use a modest resolution for smooth playback. Add sun=... for a lit globe whose terminator moves as it turns.

from cleopatra.basemap.reference import relief
from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph

globe = TexturedGlobeGlyph(relief("low"), n_lon=180, n_lat=90)
anim = globe.animate(n_frames=60, revolutions=1.0, interval=50, sun=(1.0, 0.0, 0.0))
# save with cleopatra.glyphs.base.animation.save_animation (or to_gif/to_mp4),
# or matplotlib's own writers:
# from cleopatra.glyphs.base.animation import save_animation
# save_animation(anim, "globe.gif")

Aligning your own geometry with the globe (the tilt transform)#

The glyph places the sphere with a fixed transform: it spins about the polar axis, then leans that axis tilt_deg from vertical about the world x axis — exactly the R_tilt @ R_z matrix rotation_matrix(spin) returns. To place your own scene geometry — a marker on the surface, a ring in the equatorial plane, an orbit plane — so it sits consistently with the rendered globe, push it through the same transform with transform(points, spin=...) (or grab the (3, 3) matrix with rotation_matrix(spin)). The body frame is the unit sphere: +z at the north pole, so a surface point at (lon, lat) is [cos(lat)·cos(lon), cos(lat)·sin(lon), sin(lat)] and the equatorial plane is z = 0.

import numpy as np
from cleopatra.basemap.reference import relief
from cleopatra.glyphs.globe.textured_globe_glyph import TexturedGlobeGlyph

globe = TexturedGlobeGlyph(relief("low"), tilt_deg=23.44)
fig, ax = globe.draw(spin=40.0)

# a geostationary ring in the equatorial plane, tilted+spun to match the globe
theta = np.linspace(0, 2 * np.pi, 200)
ring = np.column_stack([1.3 * np.cos(theta), 1.3 * np.sin(theta), np.zeros_like(theta)])
ring = globe.transform(ring, spin=40.0)
ax.plot(ring[:, 0], ring[:, 1], ring[:, 2])

# draw() fixes the axis limits to the unit sphere; widen them so the ring is visible
for set_lim in (ax.set_xlim, ax.set_ylim, ax.set_zlim):
    set_lim(-1.4, 1.4)