Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/math/_matrix44.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

719 lines
22 KiB
Python

# original code from package: gameobjects
# Home-page: http://code.google.com/p/gameobjects/
# Author: Will McGugan
# Download-URL: http://code.google.com/p/gameobjects/downloads/list
# Copyright (c) 2011-2024 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Sequence, Iterable, Iterator, TYPE_CHECKING, Optional
import math
import numpy as np
import numpy.typing as npt
from math import sin, cos, tan
from itertools import chain
# The pure Python implementation can't import from ._ctypes or ezdxf.math!
from ._vector import Vec3, X_AXIS, Y_AXIS, Z_AXIS, NULLVEC, Vec2
if TYPE_CHECKING:
from ezdxf.math import UVec
__all__ = ["Matrix44"]
# removed array.array because array is optimized for space not speed, and space
# optimization is not needed
def floats(items: Iterable) -> list[float]:
return [float(v) for v in items]
class Matrix44:
"""An optimized 4x4 `transformation matrix`_.
The utility functions for constructing transformations and transforming
vectors and points assumes that vectors are stored as row vectors, meaning
when multiplied, transformations are applied left to right (e.g. vAB
transforms v by A then by B).
Matrix44 initialization:
- ``Matrix44()`` returns the identity matrix.
- ``Matrix44(values)`` values is an iterable with the 16 components of
the matrix.
- ``Matrix44(row1, row2, row3, row4)`` four rows, each row with four
values.
.. _transformation matrix: https://en.wikipedia.org/wiki/Transformation_matrix
"""
__slots__ = ("_matrix",)
_matrix: npt.NDArray[np.float64]
# fmt: off
_identity = np.array([
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
], dtype=np.float64
)
# fmt: on
def __init__(self, *args):
"""
Matrix44() is the identity matrix.
Matrix44(values) values is an iterable with the 16 components of the matrix.
Matrix44(row1, row2, row3, row4) four rows, each row with four values.
"""
nargs = len(args)
if nargs == 0:
self._matrix = Matrix44._identity.copy()
elif nargs == 1:
self._matrix = np.array(args[0], dtype=np.float64)
elif nargs == 4:
self._matrix = np.array(list(chain(*args)), dtype=np.float64)
else:
raise ValueError(
"Invalid count of arguments (4 row vectors or one "
"list with 16 values)."
)
if self._matrix.shape != (16,):
raise ValueError("Invalid matrix count")
def __repr__(self) -> str:
"""Returns the representation string of the matrix in row-major order:
``Matrix44((col0, col1, col2, col3), (...), (...), (...))``
"""
def format_row(row):
return "(%s)" % ", ".join(str(value) for value in row)
return "Matrix44(%s)" % ", ".join(format_row(row) for row in self.rows())
def get_2d_transformation(self) -> tuple[float, ...]:
"""Returns a 2D transformation as a row-major matrix in a linear
array (tuple).
A more correct transformation could be implemented like so:
https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
"""
m = self._matrix
return m[0], m[1], 0.0, m[4], m[5], 0.0, m[12], m[13], 1.0
@staticmethod
def from_2d_transformation(components: Sequence[float]) -> Matrix44:
"""Returns the :class:`Matrix44` class for an affine 2D (3x3) transformation
matrix defined by 6 float values: m11, m12, m21, m22, m31, m32.
"""
if len(components) != 6:
raise ValueError(
"First 2 columns of a 3x3 matrix required: m11, m12, m21, m22, m31, m32"
)
m44 = Matrix44()
m = m44._matrix
m[0] = components[0]
m[1] = components[1]
m[4] = components[2]
m[5] = components[3]
m[12] = components[4]
m[13] = components[5]
return m44
def get_row(self, row: int) -> tuple[float, ...]:
"""Get row as list of four float values.
Args:
row: row index [0 .. 3]
"""
if 0 <= row < 4:
index = row * 4
return tuple(self._matrix[index : index + 4])
else:
raise IndexError(f"invalid row index: {row}")
def set_row(self, row: int, values: Sequence[float]) -> None:
"""Sets the values in a row.
Args:
row: row index [0 .. 3]
values: iterable of four row values
"""
if 0 <= row < 4:
index = row * 4
self._matrix[index : index + len(values)] = floats(values)
else:
raise IndexError(f"invalid row index: {row}")
def get_col(self, col: int) -> tuple[float, ...]:
"""Returns a column as a tuple of four floats.
Args:
col: column index [0 .. 3]
"""
if 0 <= col < 4:
m = self._matrix
return m[col], m[col + 4], m[col + 8], m[col + 12]
else:
raise IndexError(f"invalid row index: {col}")
def set_col(self, col: int, values: Sequence[float]):
"""Sets the values in a column.
Args:
col: column index [0 .. 3]
values: iterable of four column values
"""
if 0 <= col < 4:
m = self._matrix
a, b, c, d = values
m[col] = float(a)
m[col + 4] = float(b)
m[col + 8] = float(c)
m[col + 12] = float(d)
else:
raise IndexError(f"invalid row index: {col}")
def copy(self) -> Matrix44:
"""Returns a copy of same type."""
return self.__class__(self._matrix)
__copy__ = copy
@property
def origin(self) -> Vec3:
m = self._matrix
return Vec3(m[12], m[13], m[14])
@origin.setter
def origin(self, v: UVec) -> None:
m = self._matrix
m[12], m[13], m[14] = Vec3(v)
@property
def ux(self) -> Vec3:
return Vec3(self._matrix[0:3])
@property
def uy(self) -> Vec3:
return Vec3(self._matrix[4:7])
@property
def uz(self) -> Vec3:
return Vec3(self._matrix[8:11])
@property
def is_cartesian(self) -> bool:
"""Returns ``True`` if target coordinate system is a right handed
orthogonal coordinate system.
"""
return self.uy.cross(self.uz).normalize().isclose(self.ux.normalize())
@property
def is_orthogonal(self) -> bool:
"""Returns ``True`` if target coordinate system has orthogonal axis.
Does not check for left- or right handed orientation, any orientation
of the axis valid.
"""
ux = self.ux.normalize()
uy = self.uy.normalize()
uz = self.uz.normalize()
return (
abs(ux.dot(uy)) <= 1e-9
and abs(ux.dot(uz)) <= 1e-9
and abs(uy.dot(uz)) <= 1e-9
)
@classmethod
def scale(
cls, sx: float, sy: Optional[float] = None, sz: Optional[float] = None
) -> Matrix44:
"""Returns a scaling transformation matrix. If `sy` is ``None``,
`sy` = `sx`, and if `sz` is ``None`` `sz` = `sx`.
"""
if sy is None:
sy = sx
if sz is None:
sz = sx
# fmt: off
m = cls([
float(sx), 0., 0., 0.,
0., float(sy), 0., 0.,
0., 0., float(sz), 0.,
0., 0., 0., 1.
])
# fmt: on
return m
@classmethod
def translate(cls, dx: float, dy: float, dz: float) -> Matrix44:
"""Returns a translation matrix for translation vector (dx, dy, dz)."""
# fmt: off
return cls([
1., 0., 0., 0.,
0., 1., 0., 0.,
0., 0., 1., 0.,
float(dx), float(dy), float(dz), 1.
])
# fmt: on
@classmethod
def x_rotate(cls, angle: float) -> Matrix44:
"""Returns a rotation matrix about the x-axis.
Args:
angle: rotation angle in radians
"""
cos_a = cos(angle)
sin_a = sin(angle)
# fmt: off
return cls([
1., 0., 0., 0.,
0., cos_a, sin_a, 0.,
0., -sin_a, cos_a, 0.,
0., 0., 0., 1.
])
# fmt: on
@classmethod
def y_rotate(cls, angle: float) -> Matrix44:
"""Returns a rotation matrix about the y-axis.
Args:
angle: rotation angle in radians
"""
cos_a = cos(angle)
sin_a = sin(angle)
# fmt: off
return cls([
cos_a, 0., -sin_a, 0.,
0., 1., 0., 0.,
sin_a, 0., cos_a, 0.,
0., 0., 0., 1.
])
# fmt: on
@classmethod
def z_rotate(cls, angle: float) -> Matrix44:
"""Returns a rotation matrix about the z-axis.
Args:
angle: rotation angle in radians
"""
cos_a = cos(angle)
sin_a = sin(angle)
# fmt: off
return cls([
cos_a, sin_a, 0., 0.,
-sin_a, cos_a, 0., 0.,
0., 0., 1., 0.,
0., 0., 0., 1.
])
# fmt: on
@classmethod
def axis_rotate(cls, axis: UVec, angle: float) -> Matrix44:
"""Returns a rotation matrix about an arbitrary `axis`.
Args:
axis: rotation axis as ``(x, y, z)`` tuple or :class:`Vec3` object
angle: rotation angle in radians
"""
c = cos(angle)
s = sin(angle)
omc = 1.0 - c
x, y, z = Vec3(axis).normalize()
# fmt: off
return cls([
x * x * omc + c, y * x * omc + z * s, x * z * omc - y * s, 0.,
x * y * omc - z * s, y * y * omc + c, y * z * omc + x * s, 0.,
x * z * omc + y * s, y * z * omc - x * s, z * z * omc + c, 0.,
0., 0., 0., 1.
])
# fmt: on
@classmethod
def xyz_rotate(cls, angle_x: float, angle_y: float, angle_z: float) -> Matrix44:
"""Returns a rotation matrix for rotation about each axis.
Args:
angle_x: rotation angle about x-axis in radians
angle_y: rotation angle about y-axis in radians
angle_z: rotation angle about z-axis in radians
"""
cx = cos(angle_x)
sx = sin(angle_x)
cy = cos(angle_y)
sy = sin(angle_y)
cz = cos(angle_z)
sz = sin(angle_z)
sxsy = sx * sy
cxsy = cx * sy
# fmt: off
return cls([
cy * cz, sxsy * cz + cx * sz, -cxsy * cz + sx * sz, 0.,
-cy * sz, -sxsy * sz + cx * cz, cxsy * sz + sx * cz, 0.,
sy, -sx * cy, cx * cy, 0.,
0., 0., 0., 1.
])
# fmt: on
@classmethod
def shear_xy(cls, angle_x: float = 0, angle_y: float = 0) -> Matrix44:
"""Returns a translation matrix for shear mapping (visually similar
to slanting) in the xy-plane.
Args:
angle_x: slanting angle in x direction in radians
angle_y: slanting angle in y direction in radians
"""
tx = math.tan(angle_x)
ty = math.tan(angle_y)
# fmt: off
return cls([
1., ty, 0., 0.,
tx, 1., 0., 0.,
0., 0., 1., 0.,
0., 0., 0., 1.
])
# fmt: on
@classmethod
def perspective_projection(
cls,
left: float,
right: float,
top: float,
bottom: float,
near: float,
far: float,
) -> Matrix44:
"""Returns a matrix for a 2D projection.
Args:
left: Coordinate of left of screen
right: Coordinate of right of screen
top: Coordinate of the top of the screen
bottom: Coordinate of the bottom of the screen
near: Coordinate of the near clipping plane
far: Coordinate of the far clipping plane
"""
# fmt: off
return cls([
(2. * near) / (right - left), 0., 0., 0.,
0., (2. * near) / (top - bottom), 0., 0.,
(right + left) / (right - left), (top + bottom) / (top - bottom),
-((far + near) / (far - near)), -1.,
0., 0., -((2. * far * near) / (far - near)), 0.
])
# fmt: on
@classmethod
def perspective_projection_fov(
cls, fov: float, aspect: float, near: float, far: float
) -> Matrix44:
"""Returns a matrix for a 2D projection.
Args:
fov: The field of view (in radians)
aspect: The aspect ratio of the screen (width / height)
near: Coordinate of the near clipping plane
far: Coordinate of the far clipping plane
"""
vrange = near * tan(fov / 2.0)
left = -vrange * aspect
right = vrange * aspect
bottom = -vrange
top = vrange
return cls.perspective_projection(left, right, bottom, top, near, far)
@staticmethod
def chain(*matrices: Matrix44) -> Matrix44:
"""Compose a transformation matrix from one or more `matrices`."""
transformation = Matrix44()
for matrix in matrices:
transformation *= matrix
return transformation
@staticmethod
def ucs(
ux: Vec3 = X_AXIS,
uy: Vec3 = Y_AXIS,
uz: Vec3 = Z_AXIS,
origin: Vec3 = NULLVEC,
) -> Matrix44:
"""Returns a matrix for coordinate transformation from WCS to UCS.
For transformation from UCS to WCS, transpose the returned matrix.
Args:
ux: x-axis for UCS as unit vector
uy: y-axis for UCS as unit vector
uz: z-axis for UCS as unit vector
origin: UCS origin as location vector
"""
ux_x, ux_y, ux_z = ux
uy_x, uy_y, uy_z = uy
uz_x, uz_y, uz_z = uz
or_x, or_y, or_z = origin
# fmt: off
return Matrix44((
ux_x, ux_y, ux_z, 0,
uy_x, uy_y, uy_z, 0,
uz_x, uz_y, uz_z, 0,
or_x, or_y, or_z, 1,
))
# fmt: on
def __setitem__(self, index: tuple[int, int], value: float):
"""Set (row, column) element."""
row, col = index
if 0 <= row < 4 and 0 <= col < 4:
self._matrix[row * 4 + col] = float(value)
else:
raise IndexError(f"index out of range: {index}")
def __getitem__(self, index: tuple[int, int]):
"""Get (row, column) element."""
row, col = index
if 0 <= row < 4 and 0 <= col < 4:
return self._matrix[row * 4 + col]
else:
raise IndexError(f"index out of range: {index}")
def __iter__(self) -> Iterator[float]:
"""Iterates over all matrix values."""
return iter(self._matrix)
def __mul__(self, other: Matrix44) -> Matrix44:
"""Returns a new matrix as result of the matrix multiplication with
another matrix.
"""
m1 = self._matrix.reshape(4, 4)
m2 = other._matrix.reshape(4, 4)
result = np.matmul(m1, m2)
return self.__class__(np.ravel(result))
# __matmul__ = __mul__ does not work!
def __matmul__(self, other: Matrix44) -> Matrix44:
"""Returns a new matrix as result of the matrix multiplication with
another matrix.
"""
m1 = self._matrix.reshape(4, 4)
m2 = other._matrix.reshape(4, 4)
result = np.matmul(m1, m2)
return self.__class__(np.ravel(result))
def __imul__(self, other: Matrix44) -> Matrix44:
"""Inplace multiplication with another matrix."""
m1 = self._matrix.reshape(4, 4)
m2 = other._matrix.reshape(4, 4)
result = np.matmul(m1, m2)
self._matrix = np.ravel(result)
return self
def rows(self) -> Iterator[tuple[float, ...]]:
"""Iterate over rows as 4-tuples."""
return (self.get_row(index) for index in (0, 1, 2, 3))
def columns(self) -> Iterator[tuple[float, ...]]:
"""Iterate over columns as 4-tuples."""
return (self.get_col(index) for index in (0, 1, 2, 3))
def transform(self, vector: UVec) -> Vec3:
"""Returns a transformed vertex."""
m = self._matrix
x, y, z = Vec3(vector)
# fmt: off
return Vec3(
x * m[0] + y * m[4] + z * m[8] + m[12],
x * m[1] + y * m[5] + z * m[9] + m[13],
x * m[2] + y * m[6] + z * m[10] + m[14]
)
# fmt: on
def transform_direction(self, vector: UVec, normalize=False) -> Vec3:
"""Returns a transformed direction vector without translation."""
m = self._matrix
x, y, z = Vec3(vector)
# fmt: off
v = Vec3(
x * m[0] + y * m[4] + z * m[8],
x * m[1] + y * m[5] + z * m[9],
x * m[2] + y * m[6] + z * m[10]
)
# fmt: on
return v.normalize() if normalize else v
ocs_to_wcs = transform_direction
def transform_vertices(self, vectors: Iterable[UVec]) -> Iterator[Vec3]:
"""Returns an iterable of transformed vertices."""
# fmt: off
(
m0, m1, m2, m3,
m4, m5, m6, m7,
m8, m9, m10, m11,
m12, m13, m14, m15,
) = self._matrix
# fmt: on
for vector in vectors:
x, y, z = Vec3(vector)
# fmt: off
yield Vec3(
x * m0 + y * m4 + z * m8 + m12,
x * m1 + y * m5 + z * m9 + m13,
x * m2 + y * m6 + z * m10 + m14
)
# fmt: on
def fast_2d_transform(self, points: Iterable[UVec]) -> Iterator[Vec2]:
"""Fast transformation of 2D points. For 3D input points the z-axis will be
ignored. This only works reliable if only 2D transformations have been applied
to the 4x4 matrix!
Profiling results - speed gains over :meth:`transform_vertices`:
- pure Python code: ~1.6x
- Python with C-extensions: less than 1.1x
- PyPy 3.8: ~4.3x
But speed isn't everything, returning the processed input points as :class:`Vec2`
instances is another advantage.
.. versionadded:: 1.1
"""
m = self._matrix
m0 = m[0]
m1 = m[1]
m4 = m[4]
m5 = m[5]
m12 = m[12]
m13 = m[13]
for pnt in points:
v = Vec2(pnt)
x = v.x
y = v.y
yield Vec2(x * m0 + y * m4 + m12, x * m1 + y * m5 + m13)
def transform_array_inplace(self, array: np.ndarray, ndim: int) -> None:
"""Transforms a numpy array inplace, the argument `ndim` defines the dimensions
to transform, this allows 2D/3D transformation on arrays with more columns
e.g. a polyline array which stores points as (x, y, start_width, end_width,
bulge) values.
.. versionadded:: 1.1
"""
# This implementation exist only for compatibility to the Cython implementation!
# This version is 3.4x faster than the Cython version of Matrix44.fast_2d_transform()
# for larger point arrays but 10.5x slower than the Cython version of this method.
if ndim == 2:
m = np.array(self.get_2d_transformation(), dtype=np.float64)
m.shape = (3, 3)
elif ndim == 3:
m = np.array(self._matrix, dtype=np.float64)
m.shape = (4, 4)
else:
raise ValueError("ndim has to be 2 or 3")
v = np.matmul(
np.concatenate((array[:, :ndim], np.ones((array.shape[0], 1))), axis=1), m
)
array[:, :ndim] = v[:, :ndim].copy()
def transform_directions(
self, vectors: Iterable[UVec], normalize=False
) -> Iterator[Vec3]:
"""Returns an iterable of transformed direction vectors without
translation.
"""
m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, *_ = self._matrix
for vector in vectors:
x, y, z = Vec3(vector)
# fmt: off
v = Vec3(
x * m0 + y * m4 + z * m8,
x * m1 + y * m5 + z * m9,
x * m2 + y * m6 + z * m10
)
# fmt: on
yield v.normalize() if normalize else v
def ucs_vertex_from_wcs(self, wcs: Vec3) -> Vec3:
"""Returns an UCS vector from WCS vertex.
Works only if matrix is used as cartesian UCS without scaling.
(internal API)
"""
return self.ucs_direction_from_wcs(wcs - self.origin)
def ucs_direction_from_wcs(self, wcs: Vec3) -> Vec3:
"""Returns UCS direction vector from WCS direction.
Works only if matrix is used as cartesian UCS without scaling.
(internal API)
"""
m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, *_ = self._matrix
x, y, z = wcs
# fmt: off
return Vec3(
x * m0 + y * m1 + z * m2,
x * m4 + y * m5 + z * m6,
x * m8 + y * m9 + z * m10,
)
# fmt: on
ocs_from_wcs = ucs_direction_from_wcs
def transpose(self) -> None:
"""Swaps the rows for columns inplace."""
m = self._matrix.reshape(4, 4)
self._matrix = np.ravel(m.T)
def determinant(self) -> float:
"""Returns determinant."""
return np.linalg.det(self._matrix.reshape(4, 4))
def inverse(self) -> None:
"""Calculates the inverse of the matrix.
Raises:
ZeroDivisionError: if matrix has no inverse.
"""
try:
inverse = np.linalg.inv(self._matrix.reshape(4, 4))
except np.linalg.LinAlgError:
raise ZeroDivisionError
self._matrix = np.ravel(inverse)