From 95e017649648838a03cf2f11c025d1d3815f425c Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sat, 29 Aug 2026 16:59:57 +0200 Subject: [PATCH 1/6] feat(glyphs): expose TexturedGlobeGlyph's tilt transform for callers Add rotation_matrix(spin) and transform(points, spin) so a caller can place their own scene geometry consistently with the rendered globe instead of reimplementing the tilt. The methods return / apply the exact body-to-world transform the glyph uses -- R_tilt(x) @ R_z(spin), i.e. spin about the polar axis then the axial tilt about world x -- and _spun_mesh now goes through rotation_matrix, so the exposed transform provably lands where the mesh does. - transform() accepts a (3,) point or an (N, 3) array in the body frame (unit sphere, +z pole, equatorial plane z=0) and returns the world-space position. - The X-axis tilt default is unchanged; drop the now-redundant cached _tilt_matrix (rotation_matrix is the single source of truth). - Add tests (transform lands where the mesh does, matrix = tilt then spin, shape/validation, default-tilt mesh unchanged) and a docs example. Closes #322 --- docs/reference/textured-globe-glyph.md | 23 +++++ .../glyphs/globe/textured_globe_glyph.py | 85 +++++++++++++++++-- tests/test_textured_globe_glyph.py | 52 ++++++++++++ 3 files changed, 153 insertions(+), 7 deletions(-) diff --git a/docs/reference/textured-globe-glyph.md b/docs/reference/textured-globe-glyph.md index 1a7e7501..3bc95c2c 100644 --- a/docs/reference/textured-globe-glyph.md +++ b/docs/reference/textured-globe-glyph.md @@ -87,3 +87,26 @@ anim = globe.animate(n_frames=60, revolutions=1.0, interval=50, sun=(1.0, 0.0, 0 # 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 tilts the sphere about the world `x` axis by `tilt_deg`, then spins it about the polar axis. +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`. + +```python +import numpy as np +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]) +``` diff --git a/src/cleopatra/glyphs/globe/textured_globe_glyph.py b/src/cleopatra/glyphs/globe/textured_globe_glyph.py index c2cc6fa1..97688a02 100644 --- a/src/cleopatra/glyphs/globe/textured_globe_glyph.py +++ b/src/cleopatra/glyphs/globe/textured_globe_glyph.py @@ -119,6 +119,8 @@ class TexturedGlobeGlyph: 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 @@ -231,7 +233,6 @@ def __init__( # Filled lazily and cached by `_prepare` (sample-once contract). self._base_xyz: np.ndarray | None = None self._facecolors: np.ndarray | None = None - self._tilt_matrix: np.ndarray | None = None self._surface = None # ------------------------------------------------------------------ # @@ -292,9 +293,9 @@ def _normalize_texture(texture: np.ndarray, brightness: float) -> np.ndarray: 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)`, the per-face `facecolors` - `(n_lat - 1, n_lon - 1, 4)` sampled at face centres, and the fixed axial-tilt rotation matrix. Idempotent: - repeated calls (e.g. one per animation frame) return immediately. + 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)` sampled at face centres. Idempotent: repeated calls (e.g. one per animation + frame) return immediately. """ if self._base_xyz is not None: return @@ -324,8 +325,6 @@ def _prepare(self) -> None: row_idx, col_idx = np.meshgrid(rows, cols, indexing="ij") self._facecolors = self._texture[row_idx, col_idx] - self._tilt_matrix = self._rotation_x(self._tilt_deg) - @staticmethod def _rotation_x(deg: float) -> np.ndarray: """Return the 3x3 matrix rotating a point cloud by `deg` degrees about the x-axis.""" @@ -353,9 +352,81 @@ def _spun_mesh(self, spin: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: Returns: tuple: Three `(n_lat, n_lon)` arrays `(x, y, z)` for `Axes3D.plot_surface`. """ - coords = self._tilt_matrix @ (self._rotation_z(spin) @ self._base_xyz) + 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 self._rotation_x(self._tilt_deg) @ self._rotation_z(spin) + + def transform(self, points: np.ndarray, 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. + 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.shape[-1] != 3 or pts.ndim not in (1, 2): + raise ValueError( + f"points must be a (3,) point or an (N, 3) array; got shape {pts.shape}." + ) + matrix = self.rotation_matrix(spin) + out = np.atleast_2d(pts) @ matrix.T + return out[0] if pts.ndim == 1 else out + @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`). diff --git a/tests/test_textured_globe_glyph.py b/tests/test_textured_globe_glyph.py index 84ea2c9a..74613574 100644 --- a/tests/test_textured_globe_glyph.py +++ b/tests/test_textured_globe_glyph.py @@ -484,6 +484,58 @@ def test_world_space_sun_honoured_under_tilt(self): ) # but the pole is not the peak -> world-space, not body-space +class TestTiltTransform: + def test_rotation_matrix_identity_without_tilt_or_spin(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=0.0) + assert np.allclose(globe.rotation_matrix(0.0), np.eye(3)) + + def test_rotation_matrix_is_tilt_then_spin(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=30.0) + expected = TexturedGlobeGlyph._rotation_x( + 30.0 + ) @ TexturedGlobeGlyph._rotation_z(47.0) + assert np.allclose(globe.rotation_matrix(47.0), expected) + + def test_transform_lands_where_the_mesh_does(self, texture): + # DoD: a point pushed through the exposed transform lands where the glyph's own mesh puts it + globe = TexturedGlobeGlyph(texture, n_lon=24, n_lat=12, tilt_deg=30.0) + globe._prepare() + spin = 47.0 + mesh = np.stack(globe._spun_mesh(spin)).reshape(3, -1).T # (N, 3) world points + out = globe.transform( + globe._base_xyz.T, spin=spin + ) # base points through the public transform + assert np.allclose(out, mesh) + + def test_transform_single_point_shape_and_value(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=90.0) + out = globe.transform([0.0, 0.0, 1.0]) # north pole under a 90deg x-tilt -> -y + assert out.shape == (3,) + assert np.allclose(out, [0.0, -1.0, 0.0]) + + def test_transform_array_shape(self, texture): + globe = TexturedGlobeGlyph(texture) + out = globe.transform(np.zeros((5, 3)), spin=12.0) + assert out.shape == (5, 3) + + @pytest.mark.parametrize( + "bad", [np.zeros(2), np.zeros((4, 2)), np.zeros((2, 3, 3))] + ) + def test_transform_bad_shape_raises(self, texture, bad): + with pytest.raises(ValueError): + TexturedGlobeGlyph(texture).transform(bad) + + def test_default_tilt_mesh_unchanged(self, texture): + # the refactor keeps the X-axis default: the mesh equals R_x(tilt) @ R_z(spin) @ base + globe = TexturedGlobeGlyph(texture, n_lon=24, n_lat=12) + globe._prepare() + expected = TexturedGlobeGlyph._rotation_x(globe.tilt_deg) @ ( + TexturedGlobeGlyph._rotation_z(15.0) @ globe._base_xyz + ) + actual = np.stack(globe._spun_mesh(15.0)).reshape(3, -1) + assert np.allclose(actual, expected) + + def test_no_new_dependency(): """The globe uses mpl_toolkits.mplot3d, which ships with matplotlib -- no new dependency.""" import mpl_toolkits.mplot3d as m3d From f39bab2918988f862043c2cf1497967de5e85cbf Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sat, 29 Aug 2026 17:23:41 +0200 Subject: [PATCH 2/6] fix(globe): harden the transform methods (round-1 review) - transform() checks ndim before indexing shape[-1], so a scalar / 0-d input raises the documented ValueError instead of IndexError (M1); add scalar cases to the bad-shape test. - Wrap the rotation_matrix / transform returns in np.asarray so the declared ndarray return type is concrete, clearing two new no-any-return mypy errors under warn_return_any (M2). - Annotate transform's points as numpy.typing.ArrayLike (it accepts array-likes such as a plain list) (N2) and document that non-finite points propagate (N1). --- .../glyphs/globe/textured_globe_glyph.py | 17 ++++++++++------- tests/test_textured_globe_glyph.py | 4 +++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/cleopatra/glyphs/globe/textured_globe_glyph.py b/src/cleopatra/glyphs/globe/textured_globe_glyph.py index 97688a02..72217c16 100644 --- a/src/cleopatra/glyphs/globe/textured_globe_glyph.py +++ b/src/cleopatra/glyphs/globe/textured_globe_glyph.py @@ -57,6 +57,7 @@ import matplotlib.pyplot as plt import numpy as np +import numpy.typing as npt from matplotlib.animation import FuncAnimation from matplotlib.figure import Figure from mpl_toolkits.mplot3d import Axes3D @@ -383,9 +384,9 @@ def rotation_matrix(self, spin: float = 0.0) -> np.ndarray: See Also: transform: Apply this matrix to an `(N, 3)` array of points. """ - return self._rotation_x(self._tilt_deg) @ self._rotation_z(spin) + return np.asarray(self._rotation_x(self._tilt_deg) @ self._rotation_z(spin)) - def transform(self, points: np.ndarray, spin: float = 0.0) -> np.ndarray: + 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 @@ -395,7 +396,8 @@ def transform(self, points: np.ndarray, spin: float = 0.0) -> np.ndarray: with the rendered globe. Args: - points: A single `(3,)` point or an `(N, 3)` array of body-frame points. + 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 -- pass finite coordinates. spin: Rotation about the polar axis, in degrees (matching `draw`/`animate`'s `spin`). Returns: @@ -419,13 +421,14 @@ def transform(self, points: np.ndarray, spin: float = 0.0) -> np.ndarray: rotation_matrix: The `(3, 3)` matrix this method applies. """ pts = np.asarray(points, dtype=float) - if pts.shape[-1] != 3 or pts.ndim not in (1, 2): + 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}." ) - matrix = self.rotation_matrix(spin) - out = np.atleast_2d(pts) @ matrix.T - return out[0] if pts.ndim == 1 else out + 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: diff --git a/tests/test_textured_globe_glyph.py b/tests/test_textured_globe_glyph.py index 74613574..97d37245 100644 --- a/tests/test_textured_globe_glyph.py +++ b/tests/test_textured_globe_glyph.py @@ -519,9 +519,11 @@ def test_transform_array_shape(self, texture): assert out.shape == (5, 3) @pytest.mark.parametrize( - "bad", [np.zeros(2), np.zeros((4, 2)), np.zeros((2, 3, 3))] + "bad", + [np.zeros(2), np.zeros((4, 2)), np.zeros((2, 3, 3)), 5.0, np.array(5.0)], ) def test_transform_bad_shape_raises(self, texture, bad): + # scalar / 0-d must raise ValueError (not IndexError from indexing shape[-1]) with pytest.raises(ValueError): TexturedGlobeGlyph(texture).transform(bad) From fe79174f99b4ea31295634f218a5c5c40745ab8c Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sat, 29 Aug 2026 17:24:07 +0200 Subject: [PATCH 3/6] docs(globe): fix the tilt-transform example (import relief, show the ring) Add the missing 'from cleopatra.basemap.reference import relief' so the snippet runs as copy-pasted (L2), and widen the axis limits after plotting -- draw() pins them to the unit sphere, so the radius-1.3 geostationary ring was clipped outside the [-1, 1] cube (L3). --- docs/reference/textured-globe-glyph.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/reference/textured-globe-glyph.md b/docs/reference/textured-globe-glyph.md index 3bc95c2c..59d1556e 100644 --- a/docs/reference/textured-globe-glyph.md +++ b/docs/reference/textured-globe-glyph.md @@ -99,6 +99,7 @@ frame is the unit sphere: `+z` at the north pole, so a surface point at `(lon, l ```python 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) @@ -109,4 +110,8 @@ 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) ``` From 28b07c791c2805e79e5bc1c9735b5970e68b2744 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sat, 29 Aug 2026 17:34:56 +0200 Subject: [PATCH 4/6] test(globe): lock the transform contract (round-2 review) Assert the behaviours the code supports but nothing pinned down (100% coverage could not catch a regression in them): empty (0,3) and (1,3) shapes preserved (not squeezed), list/array-like input, rotation_matrix orthogonality + freshness (a returned matrix a caller mutates must not corrupt a later call), transform usable before _prepare/draw, and the returned array not aliasing the input. Strengthen the array test to assert the transform is applied per row (N3). --- tests/test_textured_globe_glyph.py | 46 +++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/tests/test_textured_globe_glyph.py b/tests/test_textured_globe_glyph.py index 97d37245..d505ab10 100644 --- a/tests/test_textured_globe_glyph.py +++ b/tests/test_textured_globe_glyph.py @@ -513,10 +513,14 @@ def test_transform_single_point_shape_and_value(self, texture): assert out.shape == (3,) assert np.allclose(out, [0.0, -1.0, 0.0]) - def test_transform_array_shape(self, texture): - globe = TexturedGlobeGlyph(texture) - out = globe.transform(np.zeros((5, 3)), spin=12.0) - assert out.shape == (5, 3) + def test_transform_array_applies_per_row(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=30.0) + pts = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + out = globe.transform(pts, spin=12.0) + assert out.shape == (3, 3) + # each row is transformed like a single point (not merely reshaped) + for row_in, row_out in zip(pts, out): + assert np.allclose(row_out, globe.transform(row_in, spin=12.0)) @pytest.mark.parametrize( "bad", @@ -537,6 +541,40 @@ def test_default_tilt_mesh_unchanged(self, texture): actual = np.stack(globe._spun_mesh(15.0)).reshape(3, -1) assert np.allclose(actual, expected) + def test_transform_empty_array_preserved(self, texture): + out = TexturedGlobeGlyph(texture).transform(np.zeros((0, 3))) + assert out.shape == (0, 3) + + def test_transform_1x3_not_squeezed(self, texture): + out = TexturedGlobeGlyph(texture).transform([[1.0, 0.0, 0.0]]) + assert out.shape == (1, 3) + + def test_transform_accepts_list_input(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=20.0) + from_list = globe.transform([0.0, 0.0, 1.0], spin=30.0) + from_array = globe.transform(np.array([0.0, 0.0, 1.0]), spin=30.0) + assert np.allclose(from_list, from_array) + + def test_rotation_matrix_orthogonal_and_fresh(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=23.44) + m = globe.rotation_matrix(47.0) + assert np.allclose(m @ m.T, np.eye(3)) # orthogonal + assert np.isclose(np.linalg.det(m), 1.0) # a proper rotation + m[0, 0] = 9.0 # mutating the returned matrix must not corrupt a later call + assert not np.allclose(globe.rotation_matrix(47.0), m) + + def test_transform_works_before_prepare(self, texture): + globe = TexturedGlobeGlyph(texture) + assert globe._base_xyz is None # never drawn / prepared + assert globe.transform([0.0, 0.0, 1.0], spin=10.0).shape == (3,) + + def test_transform_output_independent_of_input(self, texture): + globe = TexturedGlobeGlyph(texture, tilt_deg=0.0) + inp = np.array([1.0, 2.0, 3.0]) + out = globe.transform(inp, spin=0.0) + out[0] = 99.0 + assert inp[0] == 1.0 # the returned array does not alias the input + def test_no_new_dependency(): """The globe uses mpl_toolkits.mplot3d, which ships with matplotlib -- no new dependency.""" From 2d65360376670768609296f11491ca1904f8e9d0 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sat, 29 Aug 2026 17:35:31 +0200 Subject: [PATCH 5/6] docs(globe): align the tilt-order prose and note the inf warning Reword the docs prose to describe the transform as spin-then-tilt (R_tilt @ R_z), matching rotation_matrix's docstring, so the two don't read as opposite orders (N1); note that inf inputs to transform also emit a numpy RuntimeWarning (N2). --- docs/reference/textured-globe-glyph.md | 8 +++++--- src/cleopatra/glyphs/globe/textured_globe_glyph.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/reference/textured-globe-glyph.md b/docs/reference/textured-globe-glyph.md index 59d1556e..f80d3309 100644 --- a/docs/reference/textured-globe-glyph.md +++ b/docs/reference/textured-globe-glyph.md @@ -90,9 +90,11 @@ anim = globe.animate(n_frames=60, revolutions=1.0, interval=50, sun=(1.0, 0.0, 0 ### Aligning your own geometry with the globe (the tilt transform) -The glyph tilts the sphere about the world `x` axis by `tilt_deg`, then spins it about the polar axis. -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 +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`. diff --git a/src/cleopatra/glyphs/globe/textured_globe_glyph.py b/src/cleopatra/glyphs/globe/textured_globe_glyph.py index 72217c16..78575905 100644 --- a/src/cleopatra/glyphs/globe/textured_globe_glyph.py +++ b/src/cleopatra/glyphs/globe/textured_globe_glyph.py @@ -397,7 +397,8 @@ def transform(self, points: npt.ArrayLike, spin: float = 0.0) -> np.ndarray: 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 -- pass finite coordinates. + 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: From bdc7d35710154b1e843f69bc75d6323949b606c9 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sat, 29 Aug 2026 17:38:36 +0200 Subject: [PATCH 6/6] test(globe): one throwing call per pytest.raises (SonarCloud S5778) Construct the glyph before the pytest.raises block so only the transform() call -- the one meant to fail -- can throw inside it. --- tests/test_textured_globe_glyph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_textured_globe_glyph.py b/tests/test_textured_globe_glyph.py index d505ab10..09b4bdd8 100644 --- a/tests/test_textured_globe_glyph.py +++ b/tests/test_textured_globe_glyph.py @@ -528,8 +528,9 @@ def test_transform_array_applies_per_row(self, texture): ) def test_transform_bad_shape_raises(self, texture, bad): # scalar / 0-d must raise ValueError (not IndexError from indexing shape[-1]) + globe = TexturedGlobeGlyph(texture) with pytest.raises(ValueError): - TexturedGlobeGlyph(texture).transform(bad) + globe.transform(bad) def test_default_tilt_mesh_unchanged(self, texture): # the refactor keeps the X-axis default: the mesh equals R_x(tilt) @ R_z(spin) @ base