Source code for simvx.core.math.transforms
"""Transform2D: a 2D position, rotation and scale that composes with its parent."""
from __future__ import annotations
import math
import numpy as np
# ============================================================================
# Transform2D
# ============================================================================
[docs]
class Transform2D:
"""2D transform: position + rotation + scale with cached 3x3 matrix."""
def __init__(self, position=(0, 0), rotation=0.0, scale=(1, 1)):
self._position = np.array(position, dtype=np.float32)
self._rotation = float(rotation)
self._scale = np.array(scale, dtype=np.float32)
self._matrix: np.ndarray | None = None
self._dirty = True
@property
def position(self) -> np.ndarray:
return self._position
[docs]
@position.setter
def position(self, value):
self._position = np.array(value, dtype=np.float32)
self._dirty = True
@property
def rotation(self) -> float:
return self._rotation
[docs]
@rotation.setter
def rotation(self, value: float):
self._rotation = float(value)
self._dirty = True
@property
def scale(self) -> np.ndarray:
return self._scale
[docs]
@scale.setter
def scale(self, value):
self._scale = np.array(value, dtype=np.float32)
self._dirty = True
def _rebuild_matrix(self):
c, s = math.cos(self._rotation), math.sin(self._rotation)
sx, sy = self._scale
self._matrix = np.array(
[
[c * sx, -s * sy, self._position[0]],
[s * sx, c * sy, self._position[1]],
[0, 0, 1],
],
dtype=np.float32,
)
[docs]
@property
def matrix(self) -> np.ndarray:
if self._dirty:
self._rebuild_matrix()
self._dirty = False
return self._matrix
[docs]
def translated(self, offset) -> Transform2D:
"""Return a new transform with position shifted by offset."""
return Transform2D(self._position + np.array(offset, dtype=np.float32), self._rotation, self._scale)
[docs]
def rotated(self, angle: float) -> Transform2D:
"""Return a new transform with additional rotation (radians)."""
return Transform2D(self._position, self._rotation + angle, self._scale)
[docs]
def scaled(self, factor) -> Transform2D:
"""Return a new transform with scale multiplied by factor."""
if isinstance(factor, int | float):
f = np.array([factor, factor], dtype=np.float32)
else:
f = np.array(factor, dtype=np.float32)
return Transform2D(self._position, self._rotation, self._scale * f)
[docs]
def transform_point(self, point) -> np.ndarray:
"""Transform a 2D point through this transform."""
p = np.array(point, dtype=np.float32)
h = np.array([p[0], p[1], 1.0], dtype=np.float32)
result = self.matrix @ h
return result[:2]
[docs]
def inverse(self) -> Transform2D:
"""Return the inverse transform."""
inv_s = np.where(np.abs(self._scale) > 1e-10, 1.0 / self._scale, np.zeros_like(self._scale))
inv_r = -self._rotation
c, s = math.cos(inv_r), math.sin(inv_r)
rot = np.array([[c, -s], [s, c]], dtype=np.float32)
inv_p = -(rot @ (inv_s * self._position))
return Transform2D(inv_p, inv_r, inv_s)
[docs]
def __mul__(self, other):
if isinstance(other, Transform2D):
# Compose transforms via matrix multiplication
m = self.matrix @ other.matrix
# Extract components from composed matrix
sx = math.sqrt(m[0, 0] ** 2 + m[1, 0] ** 2)
sy = math.sqrt(m[0, 1] ** 2 + m[1, 1] ** 2)
rot = math.atan2(m[1, 0], m[0, 0])
return Transform2D((m[0, 2], m[1, 2]), rot, (sx, sy))
if isinstance(other, tuple | list | np.ndarray):
return self.transform_point(other)
return NotImplemented
[docs]
def __eq__(self, other):
if isinstance(other, Transform2D):
return (
np.allclose(self._position, other._position)
and abs(self._rotation - other._rotation) < 1e-6
and np.allclose(self._scale, other._scale)
)
return NotImplemented
[docs]
def __repr__(self):
return f"Transform2D(pos={self._position}, rot={self._rotation:.4g}, scale={self._scale})"