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})"