From f683fc785151bf087ea79faf2857747e38792778 Mon Sep 17 00:00:00 2001 From: trananso Date: Sun, 21 Apr 2024 16:39:47 -0400 Subject: [PATCH 1/9] Generalize Affine2DBase to AffineImmutable --- lib/matplotlib/backend_bases.py | 4 +- lib/matplotlib/backend_bases.pyi | 2 +- lib/matplotlib/backends/backend_svg.py | 4 +- lib/matplotlib/path.py | 4 +- lib/matplotlib/projections/polar.py | 4 +- lib/matplotlib/projections/polar.pyi | 2 +- lib/matplotlib/transforms.py | 153 ++++++++++++++++++------- lib/matplotlib/transforms.pyi | 34 +++--- 8 files changed, 142 insertions(+), 65 deletions(-) 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/transforms.py b/lib/matplotlib/transforms.py index 5003e2113930..4b52666aa055 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. @@ -1372,7 +1372,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): @@ -1578,7 +1578,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 +1607,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 +1619,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 +1631,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,11 +1827,13 @@ 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 @@ -1835,34 +1843,46 @@ class Affine2DBase(AffineBase): affine transformation, use `Affine2D`. 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 +1906,25 @@ 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) + else: + return NotImplemented + + +class Affine2D(AffineImmutable): """ A mutable 2D affine transformation. """ @@ -1906,10 +1939,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 +1999,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 +2012,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 +2147,14 @@ def skew_deg(self, xShear, yShear): return self.skew(math.radians(xShear), math.radians(yShear)) -class IdentityTransform(Affine2DBase): +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 @@ -2532,7 +2568,7 @@ def composite_transform_factory(a, b): return CompositeGenericTransform(a, b) -class BboxTransform(Affine2DBase): +class BboxTransform(AffineImmutable): """ `BboxTransform` linearly transforms points from one `Bbox` to another. """ @@ -2546,7 +2582,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 +2610,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 +2625,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 +2669,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 +2679,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 +2704,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 +2722,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 +2747,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 +2779,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 +3018,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..9ae38d66e020 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 Affine2DBase(AffineImmutable): + pass -class Affine2D(Affine2DBase): +class Affine2D(AffineImmutable): def __init__(self, matrix: ArrayLike | None = ..., **kwargs) -> None: ... @staticmethod def from_values( @@ -249,7 +251,7 @@ class Affine2D(Affine2DBase): def skew(self, xShear: float, yShear: float) -> Affine2D: ... def skew_deg(self, xShear: float, yShear: float) -> Affine2D: ... -class IdentityTransform(Affine2DBase): ... +class IdentityTransform(AffineImmutable): ... class _BlendedMixin: def __eq__(self, other: object) -> bool: ... @@ -288,24 +290,24 @@ class CompositeAffine2D(Affine2DBase): 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 +335,7 @@ def offset_copy( y: float = ..., units: Literal["inches", "points", "dots"] = ..., ) -> Transform: ... +def matrix_transform( + vertices: ArrayLike, + mtx: ArrayLike +) -> ArrayLike: ... From 91d3329f60e51970383e14ca6f777fe49eca7474 Mon Sep 17 00:00:00 2001 From: trananso Date: Sun, 21 Apr 2024 16:42:33 -0400 Subject: [PATCH 2/9] Add ability to blend any number of transforms --- lib/matplotlib/tests/test_transforms.py | 4 +- lib/matplotlib/transforms.py | 193 ++++++++++++++---------- lib/matplotlib/transforms.pyi | 17 +-- 3 files changed, 120 insertions(+), 94 deletions(-) diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 959814de82db..5ca756fdcde8 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -850,7 +850,7 @@ def test_str_transform(): CompositeGenericTransform( CompositeGenericTransform( TransformWrapper( - BlendedAffine2D( + BlendedAffine( IdentityTransform(), IdentityTransform())), CompositeAffine2D( @@ -864,7 +864,7 @@ def test_str_transform(): CompositeGenericTransform( PolarAffine( TransformWrapper( - BlendedAffine2D( + BlendedAffine( IdentityTransform(), IdentityTransform())), LockableBbox( diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 4b52666aa055..6a70c0d4e438 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -2203,171 +2203,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] - if (isinstance(x_points, np.ma.MaskedArray) or - isinstance(y_points, np.ma.MaskedArray)): - return np.ma.concatenate((x_points, y_points), 1) + masked = masked or isinstance(points, np.ma.MaskedArray) + all_points.append(points) + + 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. @@ -2375,10 +2402,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): @@ -2479,8 +2505,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 diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 9ae38d66e020..21427f74381e 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -258,11 +258,9 @@ class _BlendedMixin: 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: ... @@ -270,14 +268,15 @@ 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 From 025ea4f74641696d95f189e4f26885871fffc58c Mon Sep 17 00:00:00 2001 From: trananso Date: Sun, 21 Apr 2024 16:44:10 -0400 Subject: [PATCH 3/9] Add ability to compose transforms of any dimension --- lib/matplotlib/tests/test_transforms.py | 4 ++-- lib/matplotlib/transforms.py | 17 ++++++++++------- lib/matplotlib/transforms.pyi | 7 +++++-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 5ca756fdcde8..bc73b26da30f 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -853,7 +853,7 @@ def test_str_transform(): BlendedAffine( IdentityTransform(), IdentityTransform())), - CompositeAffine2D( + CompositeAffine( Affine2D().scale(1.0), Affine2D().scale(1.0))), PolarTransform( @@ -876,7 +876,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( diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 6a70c0d4e438..bc6f0dd70279 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -2454,7 +2454,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: @@ -2515,7 +2515,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*. @@ -2525,7 +2525,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 @@ -2536,10 +2536,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) @@ -2568,6 +2566,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 @@ -2591,7 +2594,7 @@ 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) return CompositeGenericTransform(a, b) diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 21427f74381e..8c6de27c35dc 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -282,11 +282,14 @@ 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(AffineImmutable): From a44e7d85d4627762dd358f9c1c044a3369ce773b Mon Sep 17 00:00:00 2001 From: trananso Date: Sun, 21 Apr 2024 16:46:31 -0400 Subject: [PATCH 4/9] Release note, deprecation notices, doc changes --- doc/_static/transforms.png | Bin 48669 -> 35532 bytes .../deprecations/28098-AT.rst | 5 +++++ doc/api/transformations.rst | 8 ++++---- .../next_whats_new/non_2d_transforms.rst | 16 ++++++++++++++++ doc/users/prev_whats_new/whats_new_1.4.rst | 9 +++++---- galleries/tutorials/artists.py | 8 ++++---- 6 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/28098-AT.rst create mode 100644 doc/users/next_whats_new/non_2d_transforms.rst diff --git a/doc/_static/transforms.png b/doc/_static/transforms.png index ab07fb57596130907882fe8b060edf89550cea5b..dac6f053443f924b3bb6f39033682abdb7812846 100644 GIT binary patch literal 35532 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 RQg%fdK4g^XS(E7-{s+&c7S{j( 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( From bea3ef0bb940537ce8df405efc531612d3da0fef Mon Sep 17 00:00:00 2001 From: trananso Date: Fri, 19 Apr 2024 16:48:03 -0400 Subject: [PATCH 5/9] Implement Affine3D --- lib/matplotlib/transforms.py | 305 +++++++++++++++++++++++++++++++++- lib/matplotlib/transforms.pyi | 27 +++ 2 files changed, 328 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index bc6f0dd70279..ba96f4df8cf4 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -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): @@ -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},)" @@ -1839,8 +1840,8 @@ class AffineImmutable(AffineBase): 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 matrix @@ -1920,6 +1921,8 @@ class Affine2DBase(AffineImmutable): 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 @@ -2147,6 +2150,298 @@ def skew_deg(self, xShear, yShear): return self.skew(math.radians(xShear), math.radians(yShear)) +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 @@ -2595,6 +2890,8 @@ def composite_transform_factory(a, b): return a elif isinstance(a, Affine2D) and isinstance(b, Affine2D): return CompositeAffine(a, b) + elif isinstance(a, Affine3D) and isinstance(b, Affine3D): + return CompositeAffine(a, b) return CompositeGenericTransform(a, b) diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 8c6de27c35dc..acdd176bf6c6 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -251,6 +251,33 @@ class Affine2D(AffineImmutable): def skew(self, xShear: float, yShear: float) -> Affine2D: ... def skew_deg(self, xShear: float, yShear: float) -> Affine2D: ... +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: From d46dec0efe44ebbbad25d8988244305c79d83750 Mon Sep 17 00:00:00 2001 From: trananso Date: Mon, 22 Apr 2024 09:48:17 -0400 Subject: [PATCH 6/9] Add test suite for Affine3D --- lib/matplotlib/tests/test_transforms.py | 514 +++++++++++++++++++++++- 1 file changed, 513 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index bc73b26da30f..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 @@ -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() From 2a5b1e3a2f25838d0b41ff79b01d218e65326fed Mon Sep 17 00:00:00 2001 From: trananso Date: Fri, 3 May 2024 16:30:42 -0400 Subject: [PATCH 7/9] Add transforms needed for Axes3D --- lib/mpl_toolkits/mplot3d/meson.build | 1 + lib/mpl_toolkits/mplot3d/transform3d.py | 80 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 lib/mpl_toolkits/mplot3d/transform3d.py 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/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) From 1c1eb2282887321fa53d8a20ad5b77503b757ada Mon Sep 17 00:00:00 2001 From: trananso Date: Tue, 7 May 2024 17:23:29 -0400 Subject: [PATCH 8/9] Convert existing mplot3d functions to use transforms --- lib/mpl_toolkits/mplot3d/art3d.py | 122 +++++++++++++---------------- lib/mpl_toolkits/mplot3d/axes3d.py | 34 ++++---- lib/mpl_toolkits/mplot3d/axis3d.py | 18 ++--- 3 files changed, 76 insertions(+), 98 deletions(-) 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] From 6b55a0ffdb037b2b30e4a2da87235bb8ca4f3669 Mon Sep 17 00:00:00 2001 From: trananso Date: Tue, 14 May 2024 19:29:11 -0400 Subject: [PATCH 9/9] Add proj3d deprecations to mark progress --- lib/mpl_toolkits/mplot3d/proj3d.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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)