diff --git a/doc/_static/transforms.png b/doc/_static/transforms.png index ab07fb575961..dac6f053443f 100644 Binary files a/doc/_static/transforms.png and b/doc/_static/transforms.png differ diff --git a/doc/api/next_api_changes/deprecations/28098-AT.rst b/doc/api/next_api_changes/deprecations/28098-AT.rst new file mode 100644 index 000000000000..679bb05494aa --- /dev/null +++ b/doc/api/next_api_changes/deprecations/28098-AT.rst @@ -0,0 +1,5 @@ +``Affine2DBase``, ``BlendedAffine2D`` and ``CompositeAffine2D`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +...are deprecated and replaced with ``AffineImmutable``, ``BlendedAffine``, and +``CompositeAffine`` respectively. diff --git a/doc/api/transformations.rst b/doc/api/transformations.rst index 7d5dd09d28c2..1ce73dbc58c4 100644 --- a/doc/api/transformations.rst +++ b/doc/api/transformations.rst @@ -7,10 +7,10 @@ .. automodule:: matplotlib.transforms :members: TransformNode, BboxBase, Bbox, TransformedBbox, Transform, - TransformWrapper, AffineBase, Affine2DBase, Affine2D, IdentityTransform, - BlendedGenericTransform, BlendedAffine2D, blended_transform_factory, - CompositeGenericTransform, CompositeAffine2D, - composite_transform_factory, BboxTransform, BboxTransformTo, + TransformWrapper, AffineBase, AffineImmutable, Affine2DBase, Affine2D, + IdentityTransform, BlendedGenericTransform, BlendedAffine, BlendedAffine2D, + blended_transform_factory, CompositeGenericTransform, CompositeAffine, + CompositeAffine2D, composite_transform_factory, BboxTransform, BboxTransformTo, BboxTransformFrom, ScaledTranslation, TransformedPath, nonsingular, interval_contains, interval_contains_open :show-inheritance: diff --git a/doc/users/next_whats_new/non_2d_transforms.rst b/doc/users/next_whats_new/non_2d_transforms.rst new file mode 100644 index 000000000000..16fab016d0c9 --- /dev/null +++ b/doc/users/next_whats_new/non_2d_transforms.rst @@ -0,0 +1,16 @@ +Added support for Non 2-dimensional transforms +---------------------------------------------- + +Support has been added for transforms in matplotlib that aren't 2D. + +``AffineImmutable`` directly replaces ``Affine2DBase``, and introduces a ``dims`` +keyword that specifies the dimension of the transform, defaulting to 2. + +``BlendedAffine`` directly replaces ``BlendedAffine2D``, and can blend more than +two transforms, with each transform handling a different axis. + +``CompositeAffine`` directly replaces ``CompositeAffine2D``, and composes two Affine +transforms, as long as they have the same dimensions. + +``IdentityTransform`` can create identity matrices of any dimension, through the use of +the ``dims`` keyword. diff --git a/doc/users/prev_whats_new/whats_new_1.4.rst b/doc/users/prev_whats_new/whats_new_1.4.rst index eb0e93fd8883..9f02e9fa1076 100644 --- a/doc/users/prev_whats_new/whats_new_1.4.rst +++ b/doc/users/prev_whats_new/whats_new_1.4.rst @@ -155,10 +155,11 @@ every subplot and you need to make some space for legend's labels. Support for skewed transformations `````````````````````````````````` The :class:`~matplotlib.transforms.Affine2D` gained additional methods -`.skew` and `.skew_deg` to create skewed transformations. Additionally, -matplotlib internals were cleaned up to support using such transforms in -`~matplotlib.axes.Axes`. This transform is important for some plot types, -specifically the Skew-T used in meteorology. +:func:`~matplotlib.transforms.Affine2D.skew` and +:func:`~matplotlib.transforms.Affine2D.skew_deg` to create skewed transformations. +Additionally, matplotlib internals were cleaned up to support using such transforms in +`~matplotlib.axes.Axes`. This transform is important for some plot types, specifically +the Skew-T used in meteorology. .. figure:: ../../gallery/specialty_plots/images/sphx_glr_skewt_001.png :target: ../../gallery/specialty_plots/skewt.html diff --git a/galleries/tutorials/artists.py b/galleries/tutorials/artists.py index f5e4589e8a52..d6f4c6464b97 100644 --- a/galleries/tutorials/artists.py +++ b/galleries/tutorials/artists.py @@ -463,7 +463,7 @@ class in the Matplotlib API, and the one you will be working with most # In [268]: print(rect.get_data_transform()) # CompositeGenericTransform( # TransformWrapper( -# BlendedAffine2D( +# BlendedAffine( # IdentityTransform(), # IdentityTransform())), # CompositeGenericTransform( @@ -471,7 +471,7 @@ class in the Matplotlib API, and the one you will be working with most # TransformedBbox( # Bbox(x0=0.0, y0=0.0, x1=1.0, y1=1.0), # TransformWrapper( -# BlendedAffine2D( +# BlendedAffine( # IdentityTransform(), # IdentityTransform())))), # BboxTransformTo( @@ -489,7 +489,7 @@ class in the Matplotlib API, and the one you will be working with most # In [269]: print(ax.transData) # CompositeGenericTransform( # TransformWrapper( -# BlendedAffine2D( +# BlendedAffine( # IdentityTransform(), # IdentityTransform())), # CompositeGenericTransform( @@ -497,7 +497,7 @@ class in the Matplotlib API, and the one you will be working with most # TransformedBbox( # Bbox(x0=0.0, y0=0.0, x1=1.0, y1=1.0), # TransformWrapper( -# BlendedAffine2D( +# BlendedAffine( # IdentityTransform(), # IdentityTransform())))), # BboxTransformTo( diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index f4273bc03919..3503b8881b9b 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -439,10 +439,10 @@ def draw_image(self, gc, x, y, im, transform=None): im : (N, M, 4) array of `numpy.uint8` An array of RGBA pixels. - transform : `~matplotlib.transforms.Affine2DBase` + transform : `~matplotlib.transforms.AffineImmutable` If and only if the concrete backend is written such that `option_scale_image` returns ``True``, an affine transformation - (i.e., an `.Affine2DBase`) *may* be passed to `draw_image`. The + (i.e., an `.AffineImmutable`) *may* be passed to `draw_image`. The translation vector of the transformation is given in physical units (i.e., dots or pixels). Note that the transformation does not override *x* and *y*, and has to be applied *before* translating diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 075d87a6edd8..aa974db6cd45 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -91,7 +91,7 @@ class RendererBase: x: float, y: float, im: ArrayLike, - transform: transforms.Affine2DBase | None = ..., + transform: transforms.AffineImmutable | None = ..., ) -> None: ... def option_image_nocomposite(self) -> bool: ... def option_scale_image(self) -> bool: ... diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 72354b81862b..29f01e1b2721 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -22,7 +22,7 @@ from matplotlib.dates import UTC from matplotlib.path import Path from matplotlib import _path -from matplotlib.transforms import Affine2D, Affine2DBase +from matplotlib.transforms import Affine2D, AffineImmutable _log = logging.getLogger(__name__) @@ -255,7 +255,7 @@ def _generate_transform(transform_list): or type == 'translate' and value == (0, 0) or type == 'rotate' and value == (0,)): continue - if type == 'matrix' and isinstance(value, Affine2DBase): + if type == 'matrix' and isinstance(value, AffineImmutable): value = value.to_values() parts.append('{}({})'.format( type, ' '.join(_short_float_fmt(x) for x in value))) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index e72eb1a9ca73..0103a87a51de 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -1062,10 +1062,10 @@ def get_path_collection_extents( master_transform : `~matplotlib.transforms.Transform` Global transformation applied to all paths. paths : list of `Path` - transforms : list of `~matplotlib.transforms.Affine2DBase` + transforms : list of `~matplotlib.transforms.AffineImmutable` If non-empty, this overrides *master_transform*. offsets : (N, 2) array-like - offset_transform : `~matplotlib.transforms.Affine2DBase` + offset_transform : `~matplotlib.transforms.AffineImmutable` Transform applied to the offsets before offsetting the path. Notes diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 8d3e03f64e7c..da29433df024 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -155,7 +155,7 @@ def inverted(self): ) -class PolarAffine(mtransforms.Affine2DBase): +class PolarAffine(mtransforms.AffineImmutable): r""" The affine part of the polar projection. @@ -181,7 +181,7 @@ def __init__(self, scale_transform, limits): View limits of the data. The only part of its bounds that is used is the y limits (for the radius limits). """ - super().__init__() + super().__init__(dims=2) self._scale_transform = scale_transform self._limits = limits self.set_children(scale_transform, limits) diff --git a/lib/matplotlib/projections/polar.pyi b/lib/matplotlib/projections/polar.pyi index de1cbc293900..381cd99b8b0b 100644 --- a/lib/matplotlib/projections/polar.pyi +++ b/lib/matplotlib/projections/polar.pyi @@ -23,7 +23,7 @@ class PolarTransform(mtransforms.Transform): ) -> None: ... def inverted(self) -> InvertedPolarTransform: ... -class PolarAffine(mtransforms.Affine2DBase): +class PolarAffine(mtransforms.AffineImmutable): def __init__( self, scale_transform: mtransforms.Transform, limits: mtransforms.BboxBase ) -> None: ... diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 959814de82db..36d8051bc10e 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -9,7 +9,7 @@ import matplotlib.pyplot as plt import matplotlib.patches as mpatches import matplotlib.transforms as mtransforms -from matplotlib.transforms import Affine2D, Bbox, TransformedBbox +from matplotlib.transforms import Affine2D, Affine3D, Bbox, TransformedBbox from matplotlib.path import Path from matplotlib.testing.decorators import image_comparison, check_figures_equal @@ -341,6 +341,434 @@ def test_deepcopy(self): assert_array_equal(s.get_matrix(), a.get_matrix()) +class TestAffine3D: + single_point = [1.0, 1.0, 1.0] + multiple_points = [[2.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 4.0], + [5.0, 5.0, 0.0], [6.0, 6.0, 6.0]] + pivot = single_point + + def test_init(self): + Affine3D([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]]) + Affine3D(np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], + [13, 14, 15, 16]], int)) + Affine3D(np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], + [13, 14, 15, 16]], float)) + + def test_values(self): + np.random.seed(19680801) + values = np.random.random(12) + assert_array_equal(Affine3D.from_values(*values).to_values(), values) + + def test_modify_inplace(self): + # Some polar transforms require modifying the matrix in place. + trans = Affine3D() + mtx = trans.get_matrix() + mtx[0, 0] = 42 + assert_array_equal(trans.get_matrix(), [[42, 0, 0, 0], [0, 1, 0, 0], + [0, 0, 1, 0], [0, 0, 0, 1]]) + + def test_clear(self): + a = Affine3D(np.random.rand(4, 4) + 5) # Anything non-identity. + a.clear() + assert_array_equal(a.get_matrix(), [[1, 0, 0, 0], [0, 1, 0, 0], + [0, 0, 1, 0], [0, 0, 0, 1]]) + + def test_rotate(self): + r_pi_2 = [Affine3D().rotate(np.pi / 2, dim) for dim in range(3)] + r90 = [Affine3D().rotate_deg(90, dim) for dim in range(3)] + + assert_array_almost_equal(r90[0].transform(self.single_point), [1, -1, 1]) + assert_array_almost_equal(r90[1].transform(self.single_point), [1, 1, -1]) + assert_array_almost_equal(r90[2].transform(self.single_point), [-1, 1, 1]) + + assert_array_almost_equal(r90[0].transform(self.multiple_points), [ + [2, 0, 0], [0, 0, 3], [0, -4, 0], [5, 0, 5], [6, -6, 6]]) + assert_array_almost_equal(r90[1].transform(self.multiple_points), [ + [0, 0, -2], [0, 3, 0], [4, 0, 0], [0, 5, -5], [6, 6, -6]]) + assert_array_almost_equal(r90[2].transform(self.multiple_points), [ + [0, 2, 0], [-3, 0, 0], [0, 0, 4], [-5, 5, 0], [-6, 6, 6]]) + + r_pi = [Affine3D().rotate(np.pi, dim) for dim in range(3)] + r180 = [Affine3D().rotate_deg(180, dim) for dim in range(3)] + + assert_array_almost_equal(r180[0].transform(self.single_point), [1, -1, -1]) + assert_array_almost_equal(r180[1].transform(self.single_point), [-1, 1, -1]) + assert_array_almost_equal(r180[2].transform(self.single_point), [-1, -1, 1]) + + assert_array_almost_equal(r180[0].transform(self.multiple_points), [ + [2, 0, 0], [0, -3, 0], [0, 0, -4], [5, -5, 0], [6, -6, -6]]) + assert_array_almost_equal(r180[1].transform(self.multiple_points), [ + [-2, 0, 0], [0, 3, 0], [0, 0, -4], [-5, 5, 0], [-6, 6, -6]]) + assert_array_almost_equal(r180[2].transform(self.multiple_points), [ + [-2, 0, 0], [0, -3, 0], [0, 0, 4], [-5, -5, 0], [-6, -6, 6]]) + + r_pi_3_2 = [Affine3D().rotate(3 * np.pi / 2, dim) for dim in range(3)] + r270 = [Affine3D().rotate_deg(270, dim) for dim in range(3)] + + assert_array_almost_equal(r270[0].transform(self.single_point), [1, 1, -1]) + assert_array_almost_equal(r270[1].transform(self.single_point), [-1, 1, 1]) + assert_array_almost_equal(r270[2].transform(self.single_point), [1, -1, 1]) + + assert_array_almost_equal(r270[0].transform(self.multiple_points), [ + [2, 0, 0], [0, 0, -3], [0, 4, 0], [5, 0, -5], [6, 6, -6]]) + assert_array_almost_equal(r270[1].transform(self.multiple_points), [ + [0, 0, 2], [0, 3, 0], [-4, 0, 0], [0, 5, 5], [-6, 6, 6]]) + assert_array_almost_equal(r270[2].transform(self.multiple_points), [ + [0, -2, 0], [3, 0, 0], [0, 0, 4], [5, -5, 0], [6, -6, 6]]) + + for dim in range(3): + assert_array_equal(r_pi_2[dim].get_matrix(), r90[dim].get_matrix()) + assert_array_equal(r_pi[dim].get_matrix(), r180[dim].get_matrix()) + assert_array_equal(r_pi_3_2[dim].get_matrix(), r270[dim].get_matrix()) + assert_array_almost_equal( + (r90[dim] + r90[dim]).get_matrix(), r180[dim].get_matrix()) + assert_array_almost_equal( + (r90[dim] + r180[dim]).get_matrix(), r270[dim].get_matrix()) + + def test_rotate_around(self): + r_pi_2 = [Affine3D().rotate_around(*self.pivot, np.pi / 2, dim) + for dim in range(3)] + r90 = [Affine3D().rotate_deg_around(*self.pivot, 90, dim) for dim in range(3)] + + assert_array_almost_equal(r90[0].transform(self.multiple_points), [ + [2, 2, 0], [0, 2, 3], [0, -2, 0], [5, 2, 5], [6, -4, 6]]) + assert_array_almost_equal(r90[1].transform(self.multiple_points), [ + [0, 0, 0], [0, 3, 2], [4, 0, 2], [0, 5, -3], [6, 6, -4]]) + assert_array_almost_equal(r90[2].transform(self.multiple_points), [ + [2, 2, 0], [-1, 0, 0], [2, 0, 4], [-3, 5, 0], [-4, 6, 6]]) + + r_pi = [Affine3D().rotate_around(*self.pivot, np.pi, dim) for dim in range(3)] + r180 = [Affine3D().rotate_deg_around(*self.pivot, 180, dim) for dim in range(3)] + + assert_array_almost_equal(r180[0].transform(self.multiple_points), [ + [2, 2, 2], [0, -1, 2], [0, 2, -2], [5, -3, 2], [6, -4, -4]]) + assert_array_almost_equal(r180[1].transform(self.multiple_points), [ + [0, 0, 2], [2, 3, 2], [2, 0, -2], [-3, 5, 2], [-4, 6, -4]]) + assert_array_almost_equal(r180[2].transform(self.multiple_points), [ + [0, 2, 0], [2, -1, 0], [2, 2, 4], [-3, -3, 0], [-4, -4, 6]]) + + r_pi_3_2 = [Affine3D().rotate_around(*self.pivot, 3 * np.pi / 2, dim) + for dim in range(3)] + r270 = [Affine3D().rotate_deg_around(*self.pivot, 270, dim) for dim in range(3)] + + assert_array_almost_equal(r270[0].transform(self.multiple_points), [ + [2, 0, 2], [0, 0, -1], [0, 4, 2], [5, 0, -3], [6, 6, -4]]) + assert_array_almost_equal(r270[1].transform(self.multiple_points), [ + [2, 0, 2], [2, 3, 0], [-2, 0, 0], [2, 5, 5], [-4, 6, 6]]) + assert_array_almost_equal(r270[2].transform(self.multiple_points), [ + [0, 0, 0], [3, 2, 0], [0, 2, 4], [5, -3, 0], [6, -4, 6]]) + + for dim in range(3): + assert_array_almost_equal(r90[dim].transform(self.single_point), [1, 1, 1]) + assert_array_almost_equal(r180[dim].transform(self.single_point), [1, 1, 1]) + assert_array_almost_equal(r270[dim].transform(self.single_point), [1, 1, 1]) + assert_array_equal(r_pi_2[dim].get_matrix(), r90[dim].get_matrix()) + assert_array_equal(r_pi[dim].get_matrix(), r180[dim].get_matrix()) + assert_array_equal(r_pi_3_2[dim].get_matrix(), r270[dim].get_matrix()) + assert_array_almost_equal( + (r90[dim] + r90[dim]).get_matrix(), r180[dim].get_matrix()) + assert_array_almost_equal( + (r90[dim] + r180[dim]).get_matrix(), r270[dim].get_matrix()) + + def test_scale(self): + sx = Affine3D().scale(3, 1, 1) + sy = Affine3D().scale(1, -2, 1) + sz = Affine3D().scale(1, 1, 4) + trans = Affine3D().scale(3, -2, 4) + assert_array_equal((sx + sy + sz).get_matrix(), trans.get_matrix()) + assert_array_equal(trans.transform(self.single_point), [3, -2, 4]) + assert_array_equal(trans.transform(self.multiple_points), [ + [6, 0, 0], [0, -6, 0], [0, 0, 16], [15, -10, 0], [18, -12, 24]]) + + def test_skew(self): + trans_rad = Affine3D().skew(np.pi / 2, np.pi / 4, + np.pi / 6, np.pi / 8, + np.pi / 10, np.pi / 12) + trans_deg = Affine3D().skew_deg(90, 45, 30, 22.5, 18, 15) + assert_array_equal(trans_rad.get_matrix(), trans_deg.get_matrix()) + # Using ~atan(0.5), ~atan(0.25) produces roundish numbers on output. + trans = Affine3D().skew_deg(26.5650512, 14.0362435, + 14.0362435, 14.0362435, + 14.0362435, 26.5650512) + assert_array_almost_equal(trans.transform(self.single_point), [1.75, 1.5, 1.75]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [2, 0.5, 0.5], [1.5, 3, 1.5], [1, 1, 4], + [7.5, 6.25, 3.75], [10.5, 9, 10.5]]) + + def test_translate(self): + tx = Affine3D().translate(23, 0, 0) + ty = Affine3D().translate(0, 42, 0) + tz = Affine3D().translate(0, 0, -8) + trans = Affine3D().translate(23, 42, -8) + assert_array_equal((tx + ty + tz).get_matrix(), trans.get_matrix()) + assert_array_equal(trans.transform(self.single_point), [24, 43, -7]) + assert_array_equal(trans.transform(self.multiple_points), [ + [25, 42, -8], [23, 45, -8], [23, 42, -4], [28, 47, -8], [29, 48, -2]]) + + def test_rotate_plus_other(self): + trans = (Affine3D().rotate_deg(90, dim=0) + .rotate_deg_around(*self.pivot, 180, dim=1)) + trans_added = (Affine3D().rotate_deg(90, dim=0) + + Affine3D().rotate_deg_around(*self.pivot, 180, dim=1)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [1, -1, 1]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [0, 0, 2], [2, 0, -1], [2, -4, 2], [-3, 0, -3], [-4, -6, -4]]) + + trans = (Affine3D().rotate_deg(90, dim=0).scale(3, -2, 5)) + trans_added = (Affine3D().rotate_deg(90, dim=0) + Affine3D().scale(3, -2, 5)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [3, 2, 5]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [6, 0, 0], [0, 0, 15], [0, 8, 0], [15, 0, 25], [18, 12, 30]]) + + trans = (Affine3D().rotate_deg(180, dim=1) + .skew_deg(26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + trans_added = (Affine3D().rotate_deg(180, dim=1) + + Affine3D().skew_deg( + 26.5650512, 14.0362435, + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), + [-0.75, 0.5, -0.75]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [-2, -0.5, -0.5], [1.5, 3, 1.5], [-1, -1, -4], [-2.5, 3.75, 1.25], + [-4.5, 3, -4.5]]) + + trans = (Affine3D().rotate_deg(270, dim=2).translate(23, 42, -36)) + trans_added = (Affine3D().rotate_deg(270, dim=2) + + Affine3D().translate(23, 42, -36)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [24, 41, -35]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [23, 40, -36], [26, 42, -36], [23, 42, -32], [28, 37, -36], [29, 36, -30]]) + + def test_rotate_around_plus_other(self): + trans = (Affine3D().rotate_deg_around(*self.pivot, 90, dim=0) + .rotate_deg(180, dim=1)) + trans_added = (Affine3D().rotate_deg_around(*self.pivot, 90, dim=0) + + Affine3D().rotate_deg(180, dim=1)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [-1, 1, -1]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [-2, 2, 0], [0, 2, -3], [0, -2, 0], [-5, 2, -5], [-6, -4, -6]]) + + trans = Affine3D().rotate_deg_around(*self.pivot, 90, dim=0).scale(3, -2, 5) + trans_added = (Affine3D().rotate_deg_around(*self.pivot, 90) + + Affine3D().scale(3, -2, 5)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [3, -2, 5]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [6, -4, 0], [0, -4, 15], [0, 4, 0], [15, -4, 25], [18, 8, 30]]) + + trans = (Affine3D().rotate_deg_around(*self.pivot, 180, dim=1) + .skew_deg(26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + trans_added = (Affine3D().rotate_deg_around(*self.pivot, 180, dim=1) + + Affine3D().skew_deg( + 26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), + [1.75, 1.5, 1.75]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [0.5, 0.5, 2], [4, 4, 4], [1.5, 0, -1.5], [0, 4.75, 3.75], [-2, 4, -2]]) + + trans = (Affine3D().rotate_deg_around(*self.pivot, 270, dim=2) + .translate(23, 42, -36)) + trans_added = (Affine3D().rotate_deg_around(*self.pivot, 270, dim=2) + + Affine3D().translate(23, 42, -36)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [24, 43, -35]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [23, 42, -36], [26, 44, -36], [23, 44, -32], [28, 39, -36], [29, 38, -30]]) + + def test_scale_plus_other(self): + trans = Affine3D().scale(3, -2, 5).rotate_deg(90, dim=0) + trans_added = Affine3D().scale(3, -2, 5) + Affine3D().rotate_deg(90, dim=0) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [3, -5, -2]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [6, 0, 0], [0, 0, -6], [0, -20, 0], [15, 0, -10], [18, -30, -12]]) + + trans = Affine3D().scale(3, -2, 5).rotate_deg_around(*self.pivot, 90, dim=0) + trans_added = (Affine3D().scale(3, -2, 5) + + Affine3D().rotate_deg_around(*self.pivot, 90, dim=0)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [3, -3, -2]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [6, 2, 0], [0, 2, -6], [0, -18, 0], [15, 2, -10], [18, -28, -12]]) + + trans = (Affine3D().scale(3, -2, 5) + .skew_deg(26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + trans_added = (Affine3D().scale(3, -2, 5) + + Affine3D().skew_deg( + 26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [3.25, 0, 4.75]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [6, 1.5, 1.5], [-3, -6, -3], [5, 5, 20], [10, -6.25, -1.25], + [19.5, 0, 28.5]]) + + trans = (Affine3D().scale(3, -2, 5).translate(23, 42, -36)) + trans_added = (Affine3D().scale(3, -2, 5) + + Affine3D().translate(23, 42, -36)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [26, 40, -31]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [29, 42, -36], [23, 36, -36], [23, 42, -16], [38, 32, -36], [41, 30, -6]]) + + def test_skew_plus_other(self): + # Using ~atan(0.5), ~atan(0.25) produces roundish numbers on output. + skew_angles = [26.5650512, 14.0362435, + 14.0362435, 14.0362435, + 14.0362435, 26.5650512] + + trans = Affine3D().skew_deg(*skew_angles).rotate_deg(90, dim=0) + trans_added = (Affine3D().skew_deg(*skew_angles) + + Affine3D().rotate_deg(90, dim=0)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), + [1.75, -1.75, 1.5]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [2, -0.5, 0.5], [1.5, -1.5, 3], [1, -4, 1], [7.5, -3.75, 6.25], + [10.5, -10.5, 9]]) + + trans = (Affine3D().skew_deg(*skew_angles) + .rotate_deg_around(*self.pivot, 180, dim=1)) + trans_added = (Affine3D().skew_deg(*skew_angles) + + Affine3D().rotate_deg_around(*self.pivot, 180, dim=1)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), + [0.25, 1.5, 0.25]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [0, 0.5, 1.5], [0.5, 3, 0.5], [1, 1, -2], [-5.5, 6.25, -1.75], + [-8.5, 9, -8.5]]) + + trans = Affine3D().skew_deg(*skew_angles).scale(3, -2, 5) + trans_added = Affine3D().skew_deg(*skew_angles) + Affine3D().scale(3, -2, 5) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [5.25, -3, 8.75]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [6, -1, 2.5], [4.5, -6, 7.5], [3, -2, 20], [22.5, -12.5, 18.75], + [31.5, -18, 52.5]]) + + trans = (Affine3D().skew_deg(*skew_angles).translate(23, 42, -36)) + trans_added = (Affine3D().skew_deg(*skew_angles) + + Affine3D().translate(23, 42, -36)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), + [24.75, 43.5, -34.25]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [25, 42.5, -35.5], [24.5, 45, -34.5], [24, 43, -32], [30.5, 48.25, -32.25], + [33.5, 51, -25.5]]) + + def test_translate_plus_other(self): + trans = Affine3D().translate(23, 42, -36).rotate_deg(90, dim=0) + trans_added = (Affine3D().translate(23, 42, -36) + + Affine3D().rotate_deg(90, dim=0)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [24, 35, 43]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [25, 36, 42], [23, 36, 45], [23, 32, 42], [28, 36, 47], [29, 30, 48]]) + + trans = (Affine3D().translate(23, 42, -36) + .rotate_deg_around(*self.pivot, 180, dim=1)) + trans_added = (Affine3D().translate(23, 42, -36) + + Affine3D().rotate_deg_around(*self.pivot, 180, dim=1)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [-22, 43, 37]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [-23, 42, 38], [-21, 45, 38], [-21, 42, 34], [-26, 47, 38], [-27, 48, 32]]) + + trans = Affine3D().translate(23, 42, -36).scale(3, -2, 5) + trans_added = Affine3D().translate(23, 42, -36) + Affine3D().scale(3, -2, 5) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), [72, -86, -175]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [75, -84, -180], [69, -90, -180], [69, -84, -160], [84, -94, -180], + [87, -96, -150]]) + + trans = (Affine3D().translate(23, 42, -36) + .skew_deg(26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + trans_added = (Affine3D().translate(23, 42, -36) + Affine3D() + .skew_deg( + 26.5650512, 14.0362435, # ~atan(0.5), ~atan(0.25) + 14.0362435, 14.0362435, + 14.0362435, 26.5650512)) + assert_array_equal(trans.get_matrix(), trans_added.get_matrix()) + assert_array_almost_equal(trans.transform(self.single_point), + [36.75, 40.25, -7.5]) + assert_array_almost_equal(trans.transform(self.multiple_points), [ + [37, 39.25, -8.75], [36.5, 41.75, -7.75], [36, 39.75, -5.25], + [42.5, 45, -5.5], [45.5, 47.75, 1.25]]) + + def test_invalid_transform(self): + t = mtransforms.Affine3D() + # For consistency, Affine3D.transform raises the same exceptions as Affine2D + with pytest.raises(ValueError): + t.transform(1) + with pytest.raises(ValueError): + t.transform([[[1]]]) + with pytest.raises(RuntimeError): + t.transform([]) + with pytest.raises(RuntimeError): + t.transform([1]) + with pytest.raises(ValueError): + t.transform([[1]]) + with pytest.raises(ValueError): + t.transform([[1, 2]]) + with pytest.raises(ValueError): + t.transform([[1, 2, 3, 4]]) + + def test_copy(self): + a = mtransforms.Affine3D() + b = mtransforms.Affine3D() + s = a + b + # Updating a dependee should invalidate a copy of the dependent. + s.get_matrix() # resolve it. + s1 = copy.copy(s) + assert not s._invalid and not s1._invalid + a.translate(1, 2, 3) + assert s._invalid and s1._invalid + assert (s1.get_matrix() == a.get_matrix()).all() + # Updating a copy of a dependee shouldn't invalidate a dependent. + s.get_matrix() # resolve it. + b1 = copy.copy(b) + b1.translate(3, 4, 5) + assert not s._invalid + assert_array_equal(s.get_matrix(), a.get_matrix()) + + def test_deepcopy(self): + a = mtransforms.Affine3D() + b = mtransforms.Affine3D() + s = a + b + # Updating a dependee shouldn't invalidate a deepcopy of the dependent. + s.get_matrix() # resolve it. + s1 = copy.deepcopy(s) + assert not s._invalid and not s1._invalid + a.translate(1, 2, 3) + assert s._invalid and not s1._invalid + assert_array_equal(s1.get_matrix(), mtransforms.Affine3D().get_matrix()) + # Updating a deepcopy of a dependee shouldn't invalidate a dependent. + s.get_matrix() # resolve it. + b1 = copy.deepcopy(b) + b1.translate(3, 4, 5) + assert not s._invalid + assert_array_equal(s.get_matrix(), a.get_matrix()) + + def test_non_affine_caching(): class AssertingNonAffineTransform(mtransforms.Transform): """ @@ -525,6 +953,73 @@ def test_Affine2D_from_values(): assert_almost_equal(actual, expected) +def test_Affine3D_from_values(): + points = np.array([[0, 0, 0], + [10, 20, 30], + [-1, 0, 1], + ]) + + t = mtransforms.Affine3D.from_values(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [10, 0, 0], [-1, 0, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [0, 20, 0], [0, -2, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [0, 0, 30], [0, 0, -3]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [80, 0, 0], [0, 0, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [0, 100, 0], [0, 0, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [0, 0, 120], [0, 0, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [210, 0, 0], [7, 0, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [0, 240, 0], [0, 8, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0) + actual = t.transform(points) + expected = np.array([[0, 0, 0], [0, 0, 270], [0, 0, 9]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0) + actual = t.transform(points) + expected = np.array([[10, 0, 0], [10, 0, 0], [10, 0, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 0) + actual = t.transform(points) + expected = np.array([[0, 11, 0], [0, 11, 0], [0, 11, 0]]) + assert_almost_equal(actual, expected) + + t = mtransforms.Affine3D.from_values(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12) + actual = t.transform(points) + expected = np.array([[0, 0, 12], [0, 0, 12], [0, 0, 12]]) + assert_almost_equal(actual, expected) + + def test_affine_inverted_invalidated(): # Ensure that the an affine transform is not declared valid on access point = [1.0, 1.0] @@ -535,6 +1030,14 @@ def test_affine_inverted_invalidated(): t.translate(1.0, 1.0).get_matrix() assert_almost_equal(point, t.transform(t.inverted().transform(point))) + point = [1.0, 1.0, 1.0] + t = mtransforms.Affine3D() + + assert_almost_equal(point, t.transform(t.inverted().transform(point))) + # Change and access the transform + t.translate(1.0, 1.0, 1.0).get_matrix() + assert_almost_equal(point, t.transform(t.inverted().transform(point))) + def test_clipping_of_log(): # issue 804 @@ -850,10 +1353,10 @@ def test_str_transform(): CompositeGenericTransform( CompositeGenericTransform( TransformWrapper( - BlendedAffine2D( + BlendedAffine( IdentityTransform(), IdentityTransform())), - CompositeAffine2D( + CompositeAffine( Affine2D().scale(1.0), Affine2D().scale(1.0))), PolarTransform( @@ -864,7 +1367,7 @@ def test_str_transform(): CompositeGenericTransform( PolarAffine( TransformWrapper( - BlendedAffine2D( + BlendedAffine( IdentityTransform(), IdentityTransform())), LockableBbox( @@ -876,7 +1379,7 @@ def test_str_transform(): (0.5, 0.5), TransformedBbox( Bbox(x0=0.0, y0=0.0, x1=6.283185307179586, y1=1.0), - CompositeAffine2D( + CompositeAffine( Affine2D().scale(1.0), Affine2D().scale(1.0))), LockableBbox( @@ -897,6 +1400,10 @@ def test_transform_single_point(): r = t.transform_affine((1, 1)) assert r.shape == (2,) + t = mtransforms.Affine3D() + r = t.transform_affine((1, 1, 1)) + assert r.shape == (3,) + def test_log_transform(): # Tests that the last line runs without exception (previously the @@ -960,6 +1467,11 @@ def test_transformed_path(): [(0, 0), (r2, r2), (0, 2 * r2), (-r2, r2)], atol=1e-15) + # Transforms must be 2D + trans = mtransforms.Affine3D() + with pytest.raises(TypeError): + mtransforms.TransformedPath(path, trans) + def test_transformed_patch_path(): trans = mtransforms.Affine2D() diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 5003e2113930..ba96f4df8cf4 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -1,13 +1,13 @@ """ Matplotlib includes a framework for arbitrary geometric -transformations that is used determine the final position of all +transformations that is used to determine the final position of all elements drawn on the canvas. Transforms are composed into trees of `TransformNode` objects -whose actual value depends on their children. When the contents of -children change, their parents are automatically invalidated. The +whose actual value depends on their children. When the contents of +children change, their parents are automatically invalidated. The next time an invalidated transform is accessed, it is recomputed to -reflect those changes. This invalidation/caching approach prevents +reflect those changes. This invalidation/caching approach prevents unnecessary recomputations of transforms, and contributes to better interactive performance. @@ -1289,7 +1289,8 @@ class Transform(TransformNode): actually perform a transformation. All non-affine transformations should be subclasses of this class. - New affine transformations should be subclasses of `Affine2D`. + New affine transformations should be subclasses of `Affine2D` or + `Affine3D`. Subclasses of this class should override the following members (at minimum): @@ -1372,7 +1373,7 @@ def _iter_break_from_left_to_right(self): This is equivalent to flattening the stack then yielding ``flat_stack[:i], flat_stack[i:]`` where i=0..(n-1). """ - yield IdentityTransform(), self + yield IdentityTransform(dims=self.input_dims), self @property def depth(self): @@ -1510,7 +1511,7 @@ def transform(self, values): return res[0, 0] if ndim == 1: return res.reshape(-1) - elif ndim == 2: + elif ndim == 2 or ndim == 3: return res raise ValueError( "Input values must have shape (N, {dims}) or ({dims},)" @@ -1578,7 +1579,7 @@ def transform_bbox(self, bbox): def get_affine(self): """Get the affine part of this transform.""" - return IdentityTransform() + return IdentityTransform(dims=self.input_dims) def get_matrix(self): """Get the matrix for the affine part of this transform.""" @@ -1607,6 +1608,8 @@ def transform_path(self, path): In some cases, this transform may insert curves into the path that began as line segments. """ + if self.input_dims != 2 or self.output_dims != 2: + raise NotImplementedError('Only defined in 2D') return self.transform_path_affine(self.transform_path_non_affine(path)) def transform_path_affine(self, path): @@ -1617,6 +1620,8 @@ def transform_path_affine(self, path): ``transform_path(path)`` is equivalent to ``transform_path_affine(transform_path_non_affine(values))``. """ + if self.input_dims != 2 or self.output_dims != 2: + raise NotImplementedError('Only defined in 2D') return self.get_affine().transform_path_affine(path) def transform_path_non_affine(self, path): @@ -1627,6 +1632,8 @@ def transform_path_non_affine(self, path): ``transform_path(path)`` is equivalent to ``transform_path_affine(transform_path_non_affine(values))``. """ + if self.input_dims != 2 or self.output_dims != 2: + raise NotImplementedError('Only defined in 2D') x = self.transform_non_affine(path.vertices) return Path._fast_from_codes_and_verts(x, path.codes, path) @@ -1821,48 +1828,62 @@ def get_affine(self): return self -class Affine2DBase(AffineBase): +class AffineImmutable(AffineBase): """ - The base class of all 2D affine transformations. + The base class of all affine transformations. - 2D affine transformations are performed using a 3x3 numpy array:: + Affine transformations for the n-th degree are performed using a + numpy array with shape (n+1, n+1). For example, 2D affine + transformations are performed using a 3x3 numpy array:: a c e b d f 0 0 1 - This class provides the read-only interface. For a mutable 2D - affine transformation, use `Affine2D`. + This class provides the read-only interface. For a mutable + affine transformation, use `Affine2D` or `Affine3D`. Subclasses of this class will generally only need to override a - constructor and `~.Transform.get_matrix` that generates a custom 3x3 matrix. + constructor and `~.Transform.get_matrix` that generates a custom matrix + with the appropriate shape. """ - input_dims = 2 - output_dims = 2 + def __init__(self, *args, dims=2, **kwargs): + self.input_dims = dims + self.output_dims = dims + super().__init__(*args, **kwargs) def frozen(self): # docstring inherited - return Affine2D(self.get_matrix().copy()) + return _affine_factory(self.get_matrix().copy(), self.input_dims) @property def is_separable(self): mtx = self.get_matrix() - return mtx[0, 1] == mtx[1, 0] == 0.0 + separable = True + for i in range(self.input_dims): + for j in range(i+1, self.input_dims): + separable = separable and mtx[i, j] == 0.0 + separable = separable and mtx[j, i] == 0.0 + return separable def to_values(self): """ - Return the values of the matrix as an ``(a, b, c, d, e, f)`` tuple. + Return the values of the matrix as a tuple. """ mtx = self.get_matrix() - return tuple(mtx[:2].swapaxes(0, 1).flat) + return tuple(mtx[:self.input_dims].swapaxes(0, 1).flat) @_api.rename_parameter("3.8", "points", "values") def transform_affine(self, values): mtx = self.get_matrix() + + # Default to python implementation if C implementation isn't available + transform_fn = (affine_transform if self.input_dims <= 2 else matrix_transform) + if isinstance(values, np.ma.MaskedArray): - tpoints = affine_transform(values.data, mtx) + tpoints = transform_fn(values.data, mtx) return np.ma.MaskedArray(tpoints, mask=np.ma.getmask(values)) - return affine_transform(values, mtx) + return transform_fn(values, mtx) if DEBUG: _transform_affine = transform_affine @@ -1886,12 +1907,27 @@ def inverted(self): shorthand_name = None if self._shorthand_name: shorthand_name = '(%s)-1' % self._shorthand_name - self._inverted = Affine2D(inv(mtx), shorthand_name=shorthand_name) + self._inverted = _affine_factory(inv(mtx), self.input_dims, + shorthand_name=shorthand_name) self._invalid = 0 return self._inverted -class Affine2D(Affine2DBase): +@_api.deprecated("3.9", alternative="AffineImmutable") +class Affine2DBase(AffineImmutable): + pass + + +def _affine_factory(mtx, dims, *args, **kwargs): + if dims == 2: + return Affine2D(mtx, *args, **kwargs) + elif dims == 3: + return Affine3D(mtx, *args, **kwargs) + else: + return NotImplemented + + +class Affine2D(AffineImmutable): """ A mutable 2D affine transformation. """ @@ -1906,10 +1942,9 @@ def __init__(self, matrix=None, **kwargs): If *matrix* is None, initialize with the identity transform. """ - super().__init__(**kwargs) + super().__init__(dims=2, **kwargs) if matrix is None: - # A bit faster than np.identity(3). - matrix = IdentityTransform._mtx + matrix = np.identity(3) self._mtx = matrix.copy() self._invalid = 0 @@ -1967,9 +2002,12 @@ def set_matrix(self, mtx): def set(self, other): """ Set this transformation from the frozen copy of another - `Affine2DBase` object. + 2D `AffineImmutable` object. """ - _api.check_isinstance(Affine2DBase, other=other) + _api.check_isinstance(AffineImmutable, other=other) + if (other.input_dims != 2): + raise TypeError("Mismatch between dimensions of AffineImmutable " + "and Affine2D") self._mtx = other.get_matrix() self.invalidate() @@ -1977,8 +2015,7 @@ def clear(self): """ Reset the underlying matrix to the identity transform. """ - # A bit faster than np.identity(3). - self._mtx = IdentityTransform._mtx.copy() + self._mtx = np.identity(3) self.invalidate() return self @@ -2113,12 +2150,306 @@ def skew_deg(self, xShear, yShear): return self.skew(math.radians(xShear), math.radians(yShear)) -class IdentityTransform(Affine2DBase): +class Affine3D(AffineImmutable): + """ + A mutable 3D affine transformation. + """ + + def __init__(self, matrix=None, **kwargs): + """ + Initialize an Affine transform from a 4x4 numpy float array:: + + a d g j + b e h k + c f i l + 0 0 0 1 + + If *matrix* is None, initialize with the identity transform. + """ + super().__init__(dims=3, **kwargs) + if matrix is None: + matrix = np.identity(4) + self._mtx = matrix.copy() + self._invalid = 0 + + _base_str = _make_str_method("_mtx") + + def __str__(self): + return (self._base_str() + if (self._mtx != np.diag(np.diag(self._mtx))).any() + else f"Affine3D().scale(" + f"{self._mtx[0, 0]}, " + f"{self._mtx[1, 1]}, " + f"{self._mtx[2, 2]})" + if self._mtx[0, 0] != self._mtx[1, 1] or + self._mtx[0, 0] != self._mtx[2, 2] + else f"Affine3D().scale({self._mtx[0, 0]})") + + @staticmethod + def from_values(a, b, c, d, e, f, g, h, i, j, k, l): + """ + Create a new Affine2D instance from the given values:: + + a d g j + b e h k + c f i l + 0 0 0 1 + + . + """ + return Affine3D(np.array([ + a, d, g, j, + b, e, h, k, + c, f, i, l, + 0.0, 0.0, 0.0, 1.0 + ], float).reshape((4, 4))) + + def get_matrix(self): + """ + Get the underlying transformation matrix as a 4x4 array:: + + a d g j + b e h k + c f i l + 0 0 0 1 + + . + """ + if self._invalid: + self._inverted = None + self._invalid = 0 + return self._mtx + + def set_matrix(self, mtx): + """ + Set the underlying transformation matrix from a 4x4 array:: + + a d g j + b e h k + c f i l + 0 0 0 1 + + . + """ + self._mtx = mtx + self.invalidate() + + def set(self, other): + """ + Set this transformation from the frozen copy of another + `AffineImmutable` object with input and output dimension of 3. + """ + _api.check_isinstance(AffineImmutable, other=other) + if (other.input_dims != 3): + raise TypeError("Mismatch between dimensions of AffineImmutable" + "and Affine3D") + self._mtx = other.get_matrix() + self.invalidate() + + def clear(self): + """ + Reset the underlying matrix to the identity transform. + """ + self._mtx = np.identity(4) + self.invalidate() + return self + + def rotate(self, theta, dim=0): + """ + Add a rotation (in radians) to this transform in place, along + the dimension denoted by *dim*. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + if dim == 0: + return self.rotate_around_vector([1, 0, 0], theta) + elif dim == 1: + return self.rotate_around_vector([0, 1, 0], theta) + elif dim == 2: + return self.rotate_around_vector([0, 0, 1], theta) + + self.invalidate() + return self + + def rotate_deg(self, degrees, dim=0): + """ + Add a rotation (in degrees) to this transform in place, along + the dimension denoted by *dim*. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + return self.rotate(math.radians(degrees), dim) + + def rotate_around(self, x, y, z, theta, dim=0): + """ + Add a rotation (in radians) around the point (x, y, z) in place, + along the dimension denoted by *dim*. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + return self.translate(-x, -y, -z).rotate(theta, dim).translate(x, y, z) + + def rotate_deg_around(self, x, y, z, degrees, dim=0): + """ + Add a rotation (in degrees) around the point (x, y, z) in place, + along the dimension denoted by *dim*. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + # Cast to float to avoid wraparound issues with uint8's + x, y = float(x), float(y) + return self.translate(-x, -y, -z).rotate_deg(degrees, dim).translate(x, y, z) + + def rotate_around_vector(self, vector, theta): + """ + Add a rotation (in radians) around the vector (vx, vy, vz) in place. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + vx, vy, vz = vector / np.linalg.norm(vector) + s = np.sin(theta) + c = np.cos(theta) + t = 2*np.sin(theta/2)**2 # more numerically stable than t = 1-c + rot = [[t*vx*vx + c, t*vx*vy - vz*s, t*vx*vz + vy*s, 0], + [t*vy*vx + vz*s, t*vy*vy + c, t*vy*vz - vx*s, 0], + [t*vz*vx - vy*s, t*vz*vy + vx*s, t*vz*vz + c, 0], + [0, 0, 0, 1]] + np.matmul(rot, self._mtx, out=self._mtx) + return self + + def rotate_deg_around_vector(self, vector, degrees): + """ + Add a rotation (in radians) around the vector (vx, vy, vz) in place. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + return self.rotate_around_vector(vector, math.radians(degrees)) + + def translate(self, tx, ty, tz): + """ + Add a translation in place. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + self._mtx[0, 3] += tx + self._mtx[1, 3] += ty + self._mtx[2, 3] += tz + self.invalidate() + return self + + def scale(self, sx, sy=None, sz=None): + """ + Add a scale in place. + + If a scale is not provided in the *y* or *z* directions, *sx* + will be applied for that direction. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + if sy is None: + sy = sx + + if sz is None: + sz = sx + # explicit element-wise scaling is fastest + self._mtx[0, 0] *= sx + self._mtx[0, 1] *= sx + self._mtx[0, 2] *= sx + self._mtx[0, 3] *= sx + self._mtx[1, 0] *= sy + self._mtx[1, 1] *= sy + self._mtx[1, 2] *= sy + self._mtx[1, 3] *= sy + self._mtx[2, 0] *= sz + self._mtx[2, 1] *= sz + self._mtx[2, 2] *= sz + self._mtx[2, 3] *= sz + + self.invalidate() + return self + + def skew(self, xyShear, xzShear, yxShear, yzShear, zxShear, zyShear): + """ + Add a skew in place along for each plane in the 3rd dimension. + + For example *zxShear* is the shear angle along the *zx* plane, + in radians. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + rxy = math.tan(xyShear) + rxz = math.tan(xzShear) + ryx = math.tan(yxShear) + ryz = math.tan(yzShear) + rzx = math.tan(zxShear) + rzy = math.tan(zyShear) + mtx = self._mtx + # Operating and assigning one scalar at a time is much faster. + (xx, xy, xz, x0), (yx, yy, yz, y0), (zx, zy, zz, z0), _ = mtx.tolist() + # mtx = [[1 rx 0], [ry 1 0], [0 0 1]] * mtx + + mtx[0, 0] += (rxy * yx) + (rxz * zx) + mtx[0, 1] += (rxy * yy) + (rxz * zy) + mtx[0, 2] += (rxy * yz) + (rxz * zz) + mtx[0, 3] += (rxy * y0) + (rxz * z0) + mtx[1, 0] = (ryx * xx) + yx + (ryz * zx) + mtx[1, 1] = (ryx * xy) + yy + (ryz * zy) + mtx[1, 2] = (ryx * xz) + yz + (ryz * zz) + mtx[1, 3] = (ryx * x0) + y0 + (ryz * z0) + mtx[2, 0] = (rzx * xx) + (rzy * yx) + zx + mtx[2, 1] = (rzx * xy) + (rzy * yy) + zy + mtx[2, 2] = (rzx * xz) + (rzy * yz) + zz + mtx[2, 3] = (rzx * x0) + (rzy * y0) + z0 + + self.invalidate() + return self + + def skew_deg(self, xyShear, xzShear, yxShear, yzShear, zxShear, zyShear): + """ + Add a skew in place along for each plane in the 3rd dimension. + + For example *zxShear* is the shear angle along the *zx* plane, + in radians. + + Returns *self*, so this method can easily be chained with more + calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate` + and :meth:`scale`. + """ + return self.skew( + math.radians(xyShear), + math.radians(xzShear), + math.radians(yxShear), + math.radians(yzShear), + math.radians(zxShear), + math.radians(zyShear)) + + +class IdentityTransform(AffineImmutable): """ A special class that does one thing, the identity transform, in a fast way. """ - _mtx = np.identity(3) + def __init__(self, *args, **kwargs): + super().__init__(self, *args, **kwargs) + self._mtx = np.identity(self.input_dims + 1) def frozen(self): # docstring inherited @@ -2167,171 +2498,198 @@ def inverted(self): class _BlendedMixin: - """Common methods for `BlendedGenericTransform` and `BlendedAffine2D`.""" + """Common methods for `BlendedGenericTransform` and `BlendedAffine`.""" def __eq__(self, other): - if isinstance(other, (BlendedAffine2D, BlendedGenericTransform)): - return (self._x == other._x) and (self._y == other._y) - elif self._x == self._y: - return self._x == other + num_transforms = len(self._transforms) + + if (isinstance(other, (BlendedGenericTransform, BlendedAffine)) + and num_transforms == len(other._transforms)): + return all(self._transforms[i] == other._transforms[i] + for i in range(num_transforms)) else: return NotImplemented def contains_branch_seperately(self, transform): - return (self._x.contains_branch(transform), - self._y.contains_branch(transform)) + return tuple(branch.contains_branch(transform) for branch in self._transforms) - __str__ = _make_str_method("_x", "_y") + def __str__(self): + indent = functools.partial(textwrap.indent, prefix=" " * 4) + return ( + type(self).__name__ + "(" + + ",".join([*(indent("\n" + transform.__str__()) + for transform in self._transforms)]) + + ")") class BlendedGenericTransform(_BlendedMixin, Transform): """ - A "blended" transform uses one transform for the *x*-direction, and - another transform for the *y*-direction. + A "blended" transform uses one transform for each direction - This "generic" version can handle any given child transform in the - *x*- and *y*-directions. + This "generic" version can handle any number of given child transforms, each + handling a different axis. """ - input_dims = 2 - output_dims = 2 is_separable = True pass_through = True - def __init__(self, x_transform, y_transform, **kwargs): + def __init__(self, *args, **kwargs): """ - Create a new "blended" transform using *x_transform* to transform the - *x*-axis and *y_transform* to transform the *y*-axis. + Create a new "blended" transform, with the first argument providing + a transform for the *x*-axis, the second argument providing a transform + for the *y*-axis, etc. You will generally not call this constructor directly but use the `blended_transform_factory` function instead, which can determine automatically which kind of blended transform to create. """ + self.input_dims = self.output_dims = len(args) + + for i in range(self.input_dims): + transform = args[i] + if transform.input_dims > 1 and transform.input_dims <= i: + raise TypeError("Invalid transform provided to" + "`BlendedGenericTransform`") + Transform.__init__(self, **kwargs) - self._x = x_transform - self._y = y_transform - self.set_children(x_transform, y_transform) + self.set_children(*args) + self._transforms = args self._affine = None @property def depth(self): - return max(self._x.depth, self._y.depth) + return max(transform.depth for transform in self._transforms) def contains_branch(self, other): # A blended transform cannot possibly contain a branch from two # different transforms. return False - is_affine = property(lambda self: self._x.is_affine and self._y.is_affine) - has_inverse = property( - lambda self: self._x.has_inverse and self._y.has_inverse) + is_affine = property(lambda self: all(transform.is_affine + for transform in self._transforms)) + has_inverse = property(lambda self: all(transform.has_inverse + for transform in self._transforms)) def frozen(self): # docstring inherited - return blended_transform_factory(self._x.frozen(), self._y.frozen()) + return blended_transform_factory(*(transform.frozen() + for transform in self._transforms)) @_api.rename_parameter("3.8", "points", "values") def transform_non_affine(self, values): # docstring inherited - if self._x.is_affine and self._y.is_affine: + if self.is_affine: return values - x = self._x - y = self._y - if x == y and x.input_dims == 2: - return x.transform_non_affine(values) + if all(transform == self._transforms[0] + for transform in self._transforms) and self.input_dims >= 2: + return self._transforms[0].transform_non_affine(values) - if x.input_dims == 2: - x_points = x.transform_non_affine(values)[:, 0:1] - else: - x_points = x.transform_non_affine(values[:, 0]) - x_points = x_points.reshape((len(x_points), 1)) + all_points = [] + masked = False - if y.input_dims == 2: - y_points = y.transform_non_affine(values)[:, 1:] - else: - y_points = y.transform_non_affine(values[:, 1]) - y_points = y_points.reshape((len(y_points), 1)) + for dim in range(self.input_dims): + transform = self._transforms[dim] + if transform.input_dims == 1: + points = transform.transform_non_affine(values[:, dim]) + points = points.reshape((len(points), 1)) + else: + points = transform.transform_non_affine(values)[:, dim:dim+1] + + masked = masked or isinstance(points, np.ma.MaskedArray) + all_points.append(points) - if (isinstance(x_points, np.ma.MaskedArray) or - isinstance(y_points, np.ma.MaskedArray)): - return np.ma.concatenate((x_points, y_points), 1) + if masked: + return np.ma.concatenate(tuple(all_points), 1) else: - return np.concatenate((x_points, y_points), 1) + return np.concatenate(tuple(all_points), 1) def inverted(self): # docstring inherited - return BlendedGenericTransform(self._x.inverted(), self._y.inverted()) + return BlendedGenericTransform(*(transform.inverted() + for transform in self._transforms)) def get_affine(self): # docstring inherited if self._invalid or self._affine is None: - if self._x == self._y: - self._affine = self._x.get_affine() + if all(transform == self._transforms[0] for transform in self._transforms): + self._affine = self._transforms[0].get_affine() else: - x_mtx = self._x.get_affine().get_matrix() - y_mtx = self._y.get_affine().get_matrix() - # We already know the transforms are separable, so we can skip - # setting b and c to zero. - mtx = np.array([x_mtx[0], y_mtx[1], [0.0, 0.0, 1.0]]) - self._affine = Affine2D(mtx) + mtx = np.identity(self.input_dims + 1) + for i in range(self.input_dims): + transform = self._transforms[i] + if transform.output_dims > 1: + mtx[i] = transform.get_affine().get_matrix()[i] + + self._affine = _affine_factory(mtx, dims=self.input_dims) self._invalid = 0 return self._affine -class BlendedAffine2D(_BlendedMixin, Affine2DBase): +class BlendedAffine(_BlendedMixin, AffineImmutable): """ A "blended" transform uses one transform for the *x*-direction, and another transform for the *y*-direction. This version is an optimization for the case where both child - transforms are of type `Affine2DBase`. + transforms are of type `AffineImmutable`. """ is_separable = True - def __init__(self, x_transform, y_transform, **kwargs): + def __init__(self, *args, **kwargs): """ - Create a new "blended" transform using *x_transform* to transform the - *x*-axis and *y_transform* to transform the *y*-axis. + Create a new "blended" transform, with the first argument providing + a transform for the *x*-axis, the second argument providing a transform + for the *y*-axis, etc. - Both *x_transform* and *y_transform* must be 2D affine transforms. + All provided transforms must be affine transforms. You will generally not call this constructor directly but use the `blended_transform_factory` function instead, which can determine automatically which kind of blended transform to create. """ - is_affine = x_transform.is_affine and y_transform.is_affine - is_separable = x_transform.is_separable and y_transform.is_separable - is_correct = is_affine and is_separable - if not is_correct: - raise ValueError("Both *x_transform* and *y_transform* must be 2D " - "affine transforms") - + dims = len(args) Transform.__init__(self, **kwargs) - self._x = x_transform - self._y = y_transform - self.set_children(x_transform, y_transform) + AffineImmutable.__init__(self, dims=dims, **kwargs) + + if not all(transform.is_affine and transform.is_separable + for transform in args): + raise ValueError("Given transforms must be affine") + + for i in range(self.input_dims): + transform = args[i] + if transform.input_dims > 1 and transform.input_dims <= i: + raise TypeError("Invalid transform provided to" + "`BlendedGenericTransform`") + + self._transforms = args + self.set_children(*args) - Affine2DBase.__init__(self) self._mtx = None def get_matrix(self): # docstring inherited if self._invalid: - if self._x == self._y: - self._mtx = self._x.get_matrix() + if all(transform == self._transforms[0] for transform in self._transforms): + self._mtx = self._transforms[0].get_matrix() else: - x_mtx = self._x.get_matrix() - y_mtx = self._y.get_matrix() # We already know the transforms are separable, so we can skip - # setting b and c to zero. - self._mtx = np.array([x_mtx[0], y_mtx[1], [0.0, 0.0, 1.0]]) + # setting non-diagonal values to zero. + self._mtx = np.array( + [self._transforms[i].get_affine().get_matrix()[i] + for i in range(self.input_dims)] + + [[0.0] * self.input_dims + [1.0]]) self._inverted = None self._invalid = 0 return self._mtx -def blended_transform_factory(x_transform, y_transform): +@_api.deprecated("3.9", alternative="BlendedAffine") +class BlendedAffine2D(BlendedAffine): + pass + + +def blended_transform_factory(*args): """ Create a new "blended" transform using *x_transform* to transform the *x*-axis and *y_transform* to transform the *y*-axis. @@ -2339,10 +2697,9 @@ def blended_transform_factory(x_transform, y_transform): A faster version of the blended transform is returned for the case where both child transforms are affine. """ - if (isinstance(x_transform, Affine2DBase) and - isinstance(y_transform, Affine2DBase)): - return BlendedAffine2D(x_transform, y_transform) - return BlendedGenericTransform(x_transform, y_transform) + if all(isinstance(transform, AffineImmutable) for transform in args): + return BlendedAffine(*args) + return BlendedGenericTransform(*args) class CompositeGenericTransform(Transform): @@ -2392,7 +2749,7 @@ def _invalidate_internal(self, level, invalidating_node): super()._invalidate_internal(level, invalidating_node) def __eq__(self, other): - if isinstance(other, (CompositeGenericTransform, CompositeAffine2D)): + if isinstance(other, (CompositeGenericTransform, CompositeAffine)): return self is other or (self._a == other._a and self._b == other._b) else: @@ -2443,8 +2800,9 @@ def get_affine(self): if not self._b.is_affine: return self._b.get_affine() else: - return Affine2D(np.dot(self._b.get_affine().get_matrix(), - self._a.get_affine().get_matrix())) + return _affine_factory(np.dot(self._b.get_affine().get_matrix(), + self._a.get_affine().get_matrix()), + dims=self.input_dims) def inverted(self): # docstring inherited @@ -2452,7 +2810,7 @@ def inverted(self): self._b.inverted(), self._a.inverted()) -class CompositeAffine2D(Affine2DBase): +class CompositeAffine(AffineImmutable): """ A composite transform formed by applying transform *a* then transform *b*. @@ -2462,7 +2820,7 @@ class CompositeAffine2D(Affine2DBase): def __init__(self, a, b, **kwargs): """ Create a new composite transform that is the result of - applying `Affine2DBase` *a* then `Affine2DBase` *b*. + applying `AffineImmutable` *a* then `AffineImmutable` *b*. You will generally not call this constructor directly but write ``a + b`` instead, which will automatically choose the best kind of composite @@ -2473,10 +2831,8 @@ def __init__(self, a, b, **kwargs): if a.output_dims != b.input_dims: raise ValueError("The output dimension of 'a' must be equal to " "the input dimensions of 'b'") - self.input_dims = a.input_dims - self.output_dims = b.output_dims + super().__init__(dims=a.output_dims, **kwargs) - super().__init__(**kwargs) self._a = a self._b = b self.set_children(a, b) @@ -2505,6 +2861,11 @@ def get_matrix(self): return self._mtx +@_api.deprecated("3.9", alternative="CompositeAffine") +class CompositeAffine2D(CompositeAffine): + pass + + def composite_transform_factory(a, b): """ Create a new composite transform that is the result of applying @@ -2528,11 +2889,13 @@ def composite_transform_factory(a, b): elif isinstance(b, IdentityTransform): return a elif isinstance(a, Affine2D) and isinstance(b, Affine2D): - return CompositeAffine2D(a, b) + return CompositeAffine(a, b) + elif isinstance(a, Affine3D) and isinstance(b, Affine3D): + return CompositeAffine(a, b) return CompositeGenericTransform(a, b) -class BboxTransform(Affine2DBase): +class BboxTransform(AffineImmutable): """ `BboxTransform` linearly transforms points from one `Bbox` to another. """ @@ -2546,7 +2909,7 @@ def __init__(self, boxin, boxout, **kwargs): """ _api.check_isinstance(BboxBase, boxin=boxin, boxout=boxout) - super().__init__(**kwargs) + super().__init__(dims=2, **kwargs) self._boxin = boxin self._boxout = boxout self.set_children(boxin, boxout) @@ -2574,7 +2937,7 @@ def get_matrix(self): return self._mtx -class BboxTransformTo(Affine2DBase): +class BboxTransformTo(AffineImmutable): """ `BboxTransformTo` is a transformation that linearly transforms points from the unit bounding box to a given `Bbox`. @@ -2589,7 +2952,7 @@ def __init__(self, boxout, **kwargs): """ _api.check_isinstance(BboxBase, boxout=boxout) - super().__init__(**kwargs) + super().__init__(dims=2, **kwargs) self._boxout = boxout self.set_children(boxout) self._mtx = None @@ -2633,7 +2996,7 @@ def get_matrix(self): return self._mtx -class BboxTransformFrom(Affine2DBase): +class BboxTransformFrom(AffineImmutable): """ `BboxTransformFrom` linearly transforms points from a given `Bbox` to the unit bounding box. @@ -2643,7 +3006,7 @@ class BboxTransformFrom(Affine2DBase): def __init__(self, boxin, **kwargs): _api.check_isinstance(BboxBase, boxin=boxin) - super().__init__(**kwargs) + super().__init__(dims=2, **kwargs) self._boxin = boxin self.set_children(boxin) self._mtx = None @@ -2668,13 +3031,13 @@ def get_matrix(self): return self._mtx -class ScaledTranslation(Affine2DBase): +class ScaledTranslation(AffineImmutable): """ A transformation that translates by *xt* and *yt*, after *xt* and *yt* have been transformed by *scale_trans*. """ def __init__(self, xt, yt, scale_trans, **kwargs): - super().__init__(**kwargs) + super().__init__(dims=2, **kwargs) self._t = (xt, yt) self._scale_trans = scale_trans self.set_children(scale_trans) @@ -2686,15 +3049,14 @@ def __init__(self, xt, yt, scale_trans, **kwargs): def get_matrix(self): # docstring inherited if self._invalid: - # A bit faster than np.identity(3). - self._mtx = IdentityTransform._mtx.copy() + self._mtx = np.identity(3) self._mtx[:2, 2] = self._scale_trans.transform(self._t) self._invalid = 0 self._inverted = None return self._mtx -class AffineDeltaTransform(Affine2DBase): +class AffineDeltaTransform(AffineImmutable): r""" A transform wrapper for transforming displacements between pairs of points. @@ -2712,7 +3074,7 @@ class AffineDeltaTransform(Affine2DBase): """ def __init__(self, transform, **kwargs): - super().__init__(**kwargs) + super().__init__(dims=2, **kwargs) self._base_transform = transform __str__ = _make_str_method("_base_transform") @@ -2744,6 +3106,8 @@ def __init__(self, path, transform): transform : `Transform` """ _api.check_isinstance(Transform, transform=transform) + if transform.input_dims != 2: + raise TypeError("Mismatch between input dimensions of transform and path") super().__init__() self._path = path self._transform = transform @@ -2981,3 +3345,37 @@ def offset_copy(trans, fig=None, x=0.0, y=0.0, units='inches'): y /= 72.0 # Default units are 'inches' return trans + ScaledTranslation(x, y, fig.dpi_scale_trans) + + +def matrix_transform(vertices, mtx): + """ + Transforms a vertex or set of vertices with a matrix mtx of + one dimension higher. + + Parameters + ---------- + vertices : n-element array or (m, n) array, with m vertices + mtx : (n+1, n+1) matrix + """ + values = np.asanyarray(vertices) + _, input_dims = mtx.shape + input_dims = input_dims - 1 + + if (len(values.shape) == 1): + # single point + if (values.shape == (input_dims,)): + point = mtx.dot(np.append(values, [1])) + point = point/point[-1] + return point[:input_dims] + raise RuntimeError("Invalid vertices provided to transform") + + # multiple points + if (len(values.shape) == 2 and values.shape[1] == input_dims): + points = np.hstack((values, np.ones((values.shape[0], 1)))) + points = np.dot(mtx, points.T).T + last_coords = points[:, -1] + points = points / last_coords[:, np.newaxis] + return points[:, :-1] + + raise ValueError("Dimensions of input must match the input dimensions of " + "the transform") diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 90a527e5bfc5..acdd176bf6c6 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -226,13 +226,15 @@ class AffineBase(Transform): def __init__(self, *args, **kwargs) -> None: ... def __eq__(self, other: object) -> bool: ... -class Affine2DBase(AffineBase): - input_dims: Literal[2] - output_dims: Literal[2] - def frozen(self) -> Affine2D: ... - def to_values(self) -> tuple[float, float, float, float, float, float]: ... +class AffineImmutable(AffineBase): + def __init__(self, *args, dims: int = ..., **kwargs) -> None: ... + def frozen(self) -> AffineImmutable: ... + def to_values(self) -> tuple[float]: ... -class Affine2D(Affine2DBase): +class Affine2DBase(AffineImmutable): + pass + +class Affine2D(AffineImmutable): def __init__(self, matrix: ArrayLike | None = ..., **kwargs) -> None: ... @staticmethod def from_values( @@ -249,18 +251,43 @@ class Affine2D(Affine2DBase): def skew(self, xShear: float, yShear: float) -> Affine2D: ... def skew_deg(self, xShear: float, yShear: float) -> Affine2D: ... -class IdentityTransform(Affine2DBase): ... +class Affine3D(AffineImmutable): + def __init__(self, matrix: ArrayLike | None = ..., **kwargs) -> None: ... + @staticmethod + def from_values( + a: float, b: float, c: float, d: float, e: float, f: float, g: float, + h: float, i: float, j: float, k: float, l: float + ) -> Affine3D: ... + def set_matrix(self, mtx: ArrayLike) -> None: ... + def clear(self) -> Affine3D: ... + def rotate(self, theta: float, dim: int = ...) -> Affine3D: ... + def rotate_deg(self, degrees: float, dim: int = ...) -> Affine3D: ... + def rotate_around(self, x: float, y: float, z: float, theta: float, dim: int = ... + ) -> Affine3D: ... + def rotate_deg_around( + self, x: float, y: float, z: float, degrees: float, dim: int = ... + ) -> Affine3D: ... + def rotate_around_vector(self, vector: ArrayLike, theta: float) -> Affine3D: ... + def rotate_deg_around_vector(self, vector: ArrayLike, degrees: float + ) -> Affine3D: ... + def translate(self, tx: float, ty: float, tz: float) -> Affine3D: ... + def scale(self, sx: float, sy: float | None = ..., sz: float | None = ... + ) -> Affine3D: ... + def skew(self, xyShear: float, xzShear: float, yxShear: float, yzShear: float, + zxShear: float, zyShear: float) -> Affine3D: ... + def skew_deg(self, xyShear: float, xzShear: float, yxShear: float, yzShear: float, + zxShear: float, zyShear: float) -> Affine3D: ... + +class IdentityTransform(AffineImmutable): ... class _BlendedMixin: def __eq__(self, other: object) -> bool: ... def contains_branch_seperately(self, transform: Transform) -> Sequence[bool]: ... class BlendedGenericTransform(_BlendedMixin, Transform): - input_dims: Literal[2] - output_dims: Literal[2] pass_through: bool def __init__( - self, x_transform: Transform, y_transform: Transform, **kwargs + self, *args: Transform, **kwargs ) -> None: ... @property def depth(self) -> int: ... @@ -268,44 +295,48 @@ class BlendedGenericTransform(_BlendedMixin, Transform): @property def is_affine(self) -> bool: ... -class BlendedAffine2D(_BlendedMixin, Affine2DBase): - def __init__( - self, x_transform: Transform, y_transform: Transform, **kwargs - ) -> None: ... +class BlendedAffine(_BlendedMixin, AffineImmutable): + def __init__(self, *args: Transform, **kwargs) -> None: ... + +class BlendedAffine2D(BlendedAffine): + pass def blended_transform_factory( - x_transform: Transform, y_transform: Transform -) -> BlendedGenericTransform | BlendedAffine2D: ... + *args: Transform +) -> BlendedGenericTransform | BlendedAffine: ... class CompositeGenericTransform(Transform): pass_through: bool def __init__(self, a: Transform, b: Transform, **kwargs) -> None: ... -class CompositeAffine2D(Affine2DBase): - def __init__(self, a: Affine2DBase, b: Affine2DBase, **kwargs) -> None: ... +class CompositeAffine(AffineImmutable): + def __init__(self, a: AffineImmutable, b: AffineImmutable, **kwargs) -> None: ... @property def depth(self) -> int: ... +class CompositeAffine2D(CompositeAffine): + pass + def composite_transform_factory(a: Transform, b: Transform) -> Transform: ... -class BboxTransform(Affine2DBase): +class BboxTransform(AffineImmutable): def __init__(self, boxin: BboxBase, boxout: BboxBase, **kwargs) -> None: ... -class BboxTransformTo(Affine2DBase): +class BboxTransformTo(AffineImmutable): def __init__(self, boxout: BboxBase, **kwargs) -> None: ... class BboxTransformToMaxOnly(BboxTransformTo): ... -class BboxTransformFrom(Affine2DBase): +class BboxTransformFrom(AffineImmutable): def __init__(self, boxin: BboxBase, **kwargs) -> None: ... -class ScaledTranslation(Affine2DBase): +class ScaledTranslation(AffineImmutable): def __init__( - self, xt: float, yt: float, scale_trans: Affine2DBase, **kwargs + self, xt: float, yt: float, scale_trans: AffineImmutable, **kwargs ) -> None: ... -class AffineDeltaTransform(Affine2DBase): - def __init__(self, transform: Affine2DBase, **kwargs) -> None: ... +class AffineDeltaTransform(AffineImmutable): + def __init__(self, transform: AffineImmutable, **kwargs) -> None: ... class TransformedPath(TransformNode): def __init__(self, path: Path, transform: Transform) -> None: ... @@ -333,3 +364,7 @@ def offset_copy( y: float = ..., units: Literal["inches", "points", "dots"] = ..., ) -> Transform: ... +def matrix_transform( + vertices: ArrayLike, + mtx: ArrayLike +) -> ArrayLike: ... diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 44585ccd05e7..c502f20e96c4 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -20,7 +20,6 @@ Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) from matplotlib.colors import Normalize from matplotlib.patches import Patch -from . import proj3d def _norm_angle(a): @@ -148,12 +147,11 @@ def set_3d_properties(self, z=0, zdir='z'): @artist.allow_rasterization def draw(self, renderer): position3d = np.array((self._x, self._y, self._z)) - proj = proj3d._proj_trans_points( - [position3d, position3d + self._dir_vec], self.axes.M) - dx = proj[0][1] - proj[0][0] - dy = proj[1][1] - proj[1][0] + proj = self.axes.M.transform([position3d, position3d + self._dir_vec]) + dx = proj[1][0] - proj[0][0] + dy = proj[1][1] - proj[0][1] angle = math.degrees(math.atan2(dy, dx)) - with cbook._setattr_cm(self, _x=proj[0][0], _y=proj[1][0], + with cbook._setattr_cm(self, _x=proj[0][0], _y=proj[0][1], _rotation=_norm_text_angle(angle)): mtext.Text.draw(self, renderer) self.stale = False @@ -267,8 +265,8 @@ def get_data_3d(self): @artist.allow_rasterization def draw(self, renderer): xs3d, ys3d, zs3d = self._verts3d - xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M) - self.set_data(xs, ys) + points = self.axes.M.transform(np.column_stack((xs3d, ys3d, zs3d))) + self.set_data(points[:, 0], points[:, 1]) super().draw(renderer) self.stale = False @@ -349,11 +347,11 @@ class Collection3D(Collection): def do_3d_projection(self): """Project the points according to renderer matrix.""" - xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) - for vs, _ in self._3dverts_codes] - self._paths = [mpath.Path(np.column_stack([xs, ys]), cs) - for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)] - zs = np.concatenate([zs for _, _, zs in xyzs_list]) + path_vertices = [self.axes.M.transform(vs) for vs, _ in self._3dverts_codes] + self._paths = [mpath.Path(vertices[:, :2], codes) + for (vertices, (_, codes)) + in zip(path_vertices, self._3dverts_codes)] + zs = np.concatenate(path_vertices)[:, 2] return zs.min() if len(zs) else 1e9 @@ -390,15 +388,14 @@ def do_3d_projection(self): """ Project the points according to renderer matrix. """ - xyslist = [proj3d._proj_trans_points(points, self.axes.M) - for points in self._segments3d] - segments_2d = [np.column_stack([xs, ys]) for xs, ys, zs in xyslist] + segments_3d = [self.axes.M.transform(segment) for segment in self._segments3d] + segments_2d = [segment[:, :2] for segment in segments_3d] LineCollection.set_segments(self, segments_2d) # FIXME minz = 1e9 - for xs, ys, zs in xyslist: - minz = min(minz, min(zs)) + for segment in segments_3d: + minz = min(minz, segment[0][2], segment[1][2]) return minz @@ -456,12 +453,10 @@ def get_path(self): return self._path2d def do_3d_projection(self): - s = self._segment3d - xs, ys, zs = zip(*s) - vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, - self.axes.M) - self._path2d = mpath.Path(np.column_stack([vxs, vys])) - return min(vzs) + segments = self.axes.M.transform(self._segment3d) + self._path2d = mpath.Path(segments[:, :2]) + + return min(segments[:, 2]) class PathPatch3D(Patch3D): @@ -503,12 +498,10 @@ def set_3d_properties(self, path, zs=0, zdir='z'): self._code3d = path.codes def do_3d_projection(self): - s = self._segment3d - xs, ys, zs = zip(*s) - vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, - self.axes.M) - self._path2d = mpath.Path(np.column_stack([vxs, vys]), self._code3d) - return min(vzs) + segments = self.axes.M.transform(self._segment3d) + self._path2d = mpath.Path(segments[:, :2], self._code3d) + + return min(segments[:, 2]) def _get_patch_verts(patch): @@ -610,14 +603,13 @@ def set_3d_properties(self, zs, zdir): self.stale = True def do_3d_projection(self): - xs, ys, zs = self._offsets3d - vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, - self.axes.M) - self._vzs = vzs - super().set_offsets(np.column_stack([vxs, vys])) + points = self.axes.M.transform(np.column_stack(self._offsets3d)) + super().set_offsets(points[:, :2]) - if vzs.size > 0: - return min(vzs) + self._vzs = points[:, 2] + + if self._vzs.size > 0: + return min(self._vzs) else: return np.nan @@ -751,37 +743,31 @@ def set_depthshade(self, depthshade): self.stale = True def do_3d_projection(self): - xs, ys, zs = self._offsets3d - vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, - self.axes.M) # Sort the points based on z coordinates # Performance optimization: Create a sorted index array and reorder # points and point properties according to the index array - z_markers_idx = self._z_markers_idx = np.argsort(vzs)[::-1] - self._vzs = vzs + points = self.axes.M.transform(np.column_stack(self._offsets3d)) + z_markers_idx = self._z_markers_idx = np.argsort(points[:, 2])[::-1] + self._vzs = points[:, 2] # we have to special case the sizes because of code in collections.py # as the draw method does # self.set_sizes(self._sizes, self.figure.dpi) # so we cannot rely on doing the sorting on the way out via get_* - if len(self._sizes3d) > 1: self._sizes = self._sizes3d[z_markers_idx] if len(self._linewidths3d) > 1: self._linewidths = self._linewidths3d[z_markers_idx] - PathCollection.set_offsets(self, np.column_stack((vxs, vys))) + PathCollection.set_offsets(self, points[:, :2]) # Re-order items - vzs = vzs[z_markers_idx] - vxs = vxs[z_markers_idx] - vys = vys[z_markers_idx] + points = points[z_markers_idx] # Store ordered offset for drawing purpose - self._offset_zordered = np.column_stack((vxs, vys)) - - return np.min(vzs) if vzs.size else np.nan + self._offset_zordered = points[:, :2] + return np.min(self._vzs) if self._vzs.size else np.nan @contextmanager def _use_zordered_offset(self): @@ -954,8 +940,7 @@ def get_vector(self, segments3d): xs, ys, zs = np.vstack(segments3d).T else: # vstack can't stack zero arrays. xs, ys, zs = [], [], [] - ones = np.ones(len(xs)) - self._vec = np.array([xs, ys, zs, ones]) + self._vec = np.array([xs, ys, zs]) indices = [0, *np.cumsum([len(segment) for segment in segments3d])] self._segslices = [*map(slice, indices[:-1], indices[1:])] @@ -1020,27 +1005,28 @@ def do_3d_projection(self): self._facecolor3d = self._facecolors if self._edge_is_mapped: self._edgecolor3d = self._edgecolors - txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M) - xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices] + + verts = self.axes.M.transform(np.column_stack(self._vec)) + verts_slices = [verts[sl] for sl in self._segslices] # This extra fuss is to re-order face / edge colors cface = self._facecolor3d cedge = self._edgecolor3d - if len(cface) != len(xyzlist): - cface = cface.repeat(len(xyzlist), axis=0) - if len(cedge) != len(xyzlist): + + if len(cface) != len(verts_slices): + cface = cface.repeat(len(verts_slices), axis=0) + if len(cedge) != len(verts_slices): if len(cedge) == 0: cedge = cface else: - cedge = cedge.repeat(len(xyzlist), axis=0) + cedge = cedge.repeat(len(verts_slices), axis=0) - if xyzlist: - # sort by depth (furthest drawn first) + if verts_slices: z_segments_2d = sorted( - ((self._zsortfunc(zs), np.column_stack([xs, ys]), fc, ec, idx) - for idx, ((xs, ys, zs), fc, ec) - in enumerate(zip(xyzlist, cface, cedge))), - key=lambda x: x[0], reverse=True) + ((self._zsortfunc(verts[:, 2]), verts[:, :2], fc, ec, idx) + for idx, (verts, fc, ec) + in enumerate(zip(verts_slices, cface, cedge))), + key=lambda x: x[0], reverse=True) _, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \ zip(*z_segments_2d) @@ -1061,14 +1047,12 @@ def do_3d_projection(self): # Return zorder value if self._sort_zpos is not None: - zvec = np.array([[0], [0], [self._sort_zpos], [1]]) - ztrans = proj3d._proj_transform_vec(zvec, self.axes.M) - return ztrans[2][0] - elif tzs.size > 0: + return self.axes.M.transform([0, 0, self._sort_zpos])[2] + elif len(verts) > 0: # FIXME: Some results still don't look quite right. # In particular, examine contourf3d_demo2.py # with az = -54 and elev = -45. - return np.min(tzs) + return np.min(verts[:, 2]) else: return np.nan diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index d0f5c8d2b23b..b9c06674d2ed 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -35,6 +35,7 @@ from . import art3d from . import proj3d from . import axis3d +from . import transform3d @_docstring.interpd @@ -158,8 +159,8 @@ def __init__( super().set_axis_off() # Enable drawing of axes by Axes3D class self.set_axis_on() - self.M = None - self.invM = None + self.M = mtransforms.IdentityTransform(dims=3) + self.invM = mtransforms.IdentityTransform(dims=3) self._view_margin = 1/48 # default value to match mpl3.8 self.autoscale_view() @@ -236,7 +237,7 @@ def _transformed_cube(self, vals): (maxx, miny, maxz), (maxx, maxy, maxz), (minx, maxy, maxz)] - return proj3d._proj_points(xyzs, self.M) + return self.M.transform(xyzs) def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): """ @@ -423,7 +424,7 @@ def draw(self, renderer): # add the projection matrix to the renderer self.M = self.get_proj() - self.invM = np.linalg.inv(self.M) + self.invM = self.M.inverted() collections_and_patches = ( artist for artist in self._children @@ -1200,12 +1201,8 @@ def get_proj(self): # Transform to uniform world coordinates 0-1, 0-1, 0-1 box_aspect = self._roll_to_vertical(self._box_aspect) - worldM = proj3d.world_transformation( - *self.get_xlim3d(), - *self.get_ylim3d(), - *self.get_zlim3d(), - pb_aspect=box_aspect, - ) + worldM = transform3d.WorldTransform(*self.get_xlim3d(), *self.get_ylim3d(), + *self.get_zlim3d(), pb_aspect=box_aspect) # Look into the middle of the world coordinates: R = 0.5 * box_aspect @@ -1238,21 +1235,18 @@ def get_proj(self): # Generate the view and projection transformation matrices if self._focal_length == np.inf: # Orthographic projection - viewM = proj3d._view_transformation_uvw(u, v, w, eye) - projM = proj3d._ortho_transformation(-self._dist, self._dist) + viewM = transform3d.ViewTransform(u, v, w, eye) + projM = transform3d.OrthographicTransform(-self._dist, self._dist) else: # Perspective projection # Scale the eye dist to compensate for the focal length zoom effect eye_focal = R + self._dist * ps * self._focal_length - viewM = proj3d._view_transformation_uvw(u, v, w, eye_focal) - projM = proj3d._persp_transformation(-self._dist, - self._dist, - self._focal_length) + viewM = transform3d.ViewTransform(u, v, w, eye_focal) + projM = transform3d.PerspectiveTransform(-self._dist, self._dist, + self._focal_length) # Combine all the transformation matrices to get the final projection - M0 = np.dot(viewM, worldM) - M = np.dot(projM, M0) - return M + return worldM + viewM + projM def mouse_init(self, rotate_btn=1, pan_btn=2, zoom_btn=3): """ @@ -1459,7 +1453,7 @@ def _calc_coord(self, xv, yv, renderer=None): zv = -1 / self._focal_length # Convert point on view plane to data coordinates - p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.invM)).ravel() + p1 = self.invM.transform([xv, yv, zv]) # Get the vector from the camera to the point on the view plane vec = self._get_camera_loc() - p1 diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 79b78657bdb9..bebb97e50538 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -10,7 +10,7 @@ from matplotlib import ( _api, artist, lines as mlines, axis as maxis, patches as mpatches, transforms as mtransforms, colors as mcolors) -from . import art3d, proj3d +from . import art3d def _move_from_center(coord, centers, deltas, axmask=(True, True, True)): @@ -472,16 +472,16 @@ def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, pos = edgep1.copy() pos[index] = tick.get_loc() pos[tickdir] = out_tickdir - x1, y1, z1 = proj3d.proj_transform(*pos, self.axes.M) + x1, y1, z1 = self.axes.M.transform(pos) pos[tickdir] = in_tickdir - x2, y2, z2 = proj3d.proj_transform(*pos, self.axes.M) + x2, y2, z2 = self.axes.M.transform(pos) # Get position of label labeldeltas = (tick.get_pad() + default_label_offset) * points pos[tickdir] = edgep1_tickdir pos = _move_from_center(pos, centers, labeldeltas, self._axmask()) - lx, ly, lz = proj3d.proj_transform(*pos, self.axes.M) + lx, ly, lz = self.axes.M.transform(pos) _tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly)) tick.tick1line.set_linewidth(tick_lw[tick._major]) @@ -506,7 +506,7 @@ def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers, pos = _move_from_center(outeredgep, centers, labeldeltas, self._axmask()) - olx, oly, olz = proj3d.proj_transform(*pos, self.axes.M) + olx, oly, olz = self.axes.M.transform(pos) self.offsetText.set_text(self.major.formatter.get_offset()) self.offsetText.set_position((olx, oly)) angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx))) @@ -530,7 +530,7 @@ def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers, # Three-letters (e.g., TFT, FTT) are short-hand for the array of bools # from the variable 'highs'. # --------------------------------------------------------------------- - centpt = proj3d.proj_transform(*centers, self.axes.M) + centpt = self.axes.M.transform(centers) if centpt[tickdir] > pep[tickdir, outerindex]: # if FT and if highs has an even number of Trues if (centpt[index] <= pep[index, outerindex] @@ -564,7 +564,7 @@ def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy): # Draw labels lxyz = 0.5 * (edgep1 + edgep2) lxyz = _move_from_center(lxyz, centers, labeldeltas, self._axmask()) - tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M) + tlx, tly, tlz = self.axes.M.transform(lxyz) self.label.set_position((tlx, tly)) if self.get_rotate_label(self.label.get_text()): angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx))) @@ -600,7 +600,7 @@ def draw(self, renderer): for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( minmax, maxmin, self._tick_position)): # Project the edge points along the current position - pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M) + pep = self.axes.M.transform([edgep1, edgep2]).T pep = np.asarray(pep) # The transAxes transform is used because the Text object @@ -628,7 +628,7 @@ def draw(self, renderer): for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( minmax, maxmin, self._label_position)): # See comments above - pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M) + pep = self.axes.M.transform([edgep1, edgep2]).T pep = np.asarray(pep) dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - self.axes.transAxes.transform([pep[0:2, 0]]))[0] diff --git a/lib/mpl_toolkits/mplot3d/meson.build b/lib/mpl_toolkits/mplot3d/meson.build index 2d9cade6c93c..0139a5a94ee4 100644 --- a/lib/mpl_toolkits/mplot3d/meson.build +++ b/lib/mpl_toolkits/mplot3d/meson.build @@ -4,6 +4,7 @@ python_sources = [ 'axes3d.py', 'axis3d.py', 'proj3d.py', + 'transform3d.py', ] py3.install_sources(python_sources, subdir: 'mpl_toolkits/mplot3d') diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 098a7b6f6667..a085d7ccee4b 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -7,6 +7,7 @@ from matplotlib import _api +@_api.deprecated("3.10") def world_transformation(xmin, xmax, ymin, ymax, zmin, zmax, pb_aspect=None): @@ -37,6 +38,7 @@ def rotation_about_vector(v, angle): return _rotation_about_vector(v, angle) +@_api.deprecated("3.10") def _rotation_about_vector(v, angle): """ Produce a rotation matrix for an angle in radians about a vector. @@ -93,6 +95,7 @@ def _view_axes(E, R, V, roll): return u, v, w +@_api.deprecated("3.10") def _view_transformation_uvw(u, v, w, E): """ Return the view transformation matrix. @@ -142,6 +145,7 @@ def persp_transformation(zfront, zback, focal_length): return _persp_transformation(zfront, zback, focal_length) +@_api.deprecated("3.10") def _persp_transformation(zfront, zback, focal_length): e = focal_length a = 1 # aspect ratio @@ -159,6 +163,7 @@ def ortho_transformation(zfront, zback): return _ortho_transformation(zfront, zback) +@_api.deprecated("3.10") def _ortho_transformation(zfront, zback): # note: w component in the resulting vector will be (zback-zfront), not 1 a = -(zfront + zback) @@ -170,6 +175,7 @@ def _ortho_transformation(zfront, zback): return proj_matrix +@_api.deprecated("3.10") def _proj_transform_vec(vec, M): vecw = np.dot(M, vec) w = vecw[3] @@ -178,6 +184,7 @@ def _proj_transform_vec(vec, M): return txs, tys, tzs +@_api.deprecated("3.10") def _proj_transform_vec_clip(vec, M): vecw = np.dot(M, vec) w = vecw[3] @@ -189,6 +196,7 @@ def _proj_transform_vec_clip(vec, M): return txs, tys, tzs, tis +@_api.deprecated("3.10") def inv_transform(xs, ys, zs, invM): """ Transform the points by the inverse of the projection matrix, *invM*. @@ -203,10 +211,12 @@ def inv_transform(xs, ys, zs, invM): return vecr[0], vecr[1], vecr[2] +@_api.deprecated("3.10") def _vec_pad_ones(xs, ys, zs): return np.array([xs, ys, zs, np.ones_like(xs)]) +@_api.deprecated("3.10") def proj_transform(xs, ys, zs, M): """ Transform the points by the projection matrix *M*. @@ -220,6 +230,7 @@ def proj_transform(xs, ys, zs, M): alternative="proj_transform")(proj_transform) +@_api.deprecated("3.10") def proj_transform_clip(xs, ys, zs, M): """ Transform the points by the projection matrix @@ -235,6 +246,7 @@ def proj_points(points, M): return _proj_points(points, M) +@_api.deprecated("3.10") def _proj_points(points, M): return np.column_stack(_proj_trans_points(points, M)) @@ -244,6 +256,7 @@ def proj_trans_points(points, M): return _proj_trans_points(points, M) +@_api.deprecated("3.10") def _proj_trans_points(points, M): xs, ys, zs = zip(*points) return proj_transform(xs, ys, zs, M) diff --git a/lib/mpl_toolkits/mplot3d/transform3d.py b/lib/mpl_toolkits/mplot3d/transform3d.py new file mode 100644 index 000000000000..aeecf34e87d5 --- /dev/null +++ b/lib/mpl_toolkits/mplot3d/transform3d.py @@ -0,0 +1,80 @@ +import numpy as np +import matplotlib.transforms as mtransforms + + +# These transforms break the assumption that the last row is [0, 0, 0, 1], and is +# therefore not affine. However, this is required to preserve the order that +# transforms are performed +class NonAffine3D(mtransforms.Affine3D): + pass + + +class WorldTransform(mtransforms.Affine3D): + def __init__(self, xmin, xmax, ymin, ymax, zmin, zmax, pb_aspect=None): + dx = xmax - xmin + dy = ymax - ymin + dz = zmax - zmin + if pb_aspect is not None: + ax, ay, az = pb_aspect + dx /= ax + dy /= ay + dz /= az + mtx = np.array([ + [1/dx, 0, 0, -xmin/dx], + [0, 1/dy, 0, -ymin/dy], + [0, 0, 1/dz, -zmin/dz], + [0, 0, 0, 1] + ]) + super().__init__(matrix=mtx) + + +class PerspectiveTransform(NonAffine3D): + def __init__(self, zfront, zback, focal_length): + e = focal_length + a = 1 + b = (zfront + zback) / (zfront - zback) + c = -2 * (zfront * zback) / (zfront - zback) + mtx = np.array([[e, 0, 0, 0], + [0, e/a, 0, 0], + [0, 0, b, c], + [0, 0, -1, 0]]) + super().__init__(matrix=mtx) + + +class OrthographicTransform(NonAffine3D): + def __init__(self, zfront, zback): + a = -(zfront + zback) + b = -(zfront - zback) + mtx = np.array([[2, 0, 0, 0], + [0, 2, 0, 0], + [0, 0, -2, 0], + [0, 0, a, b]]) + super().__init__(matrix=mtx) + + +class ViewTransform(mtransforms.Affine3D): + def __init__(self, u, v, w, E): + """ + Return the view transformation matrix. + + Parameters + ---------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + E : 3-element numpy array + The coordinates of the eye/camera. + """ + self._u = u + self._v = v + self._w = w + + Mr = np.eye(4) + Mt = np.eye(4) + Mr[:3, :3] = [u, v, w] + Mt[:3, -1] = -E + mtx = np.dot(Mr, Mt) + super().__init__(matrix=mtx)