Source code for jitx.transform

"""
Coordinate system transforms
============================

This module provides 2D transforms used in the JITX system to position objects
within the design tree.
"""

from __future__ import annotations
from typing import Literal, Self, overload
from collections.abc import Iterator
from math import radians, cos, sin, atan2, sqrt

from jitx.shapes import Shape, ShapeGeometry
from jitx.layerindex import LayerSet, Side

type GridPoint = tuple[int, int]
"""Grid point coordinates as (x, y) integer tuple."""

type Point = tuple[float | int, float | int]
"""2D point coordinates as (x, y) tuple."""

type Vec3D = tuple[float | int, float | int, float | int]
"""3D vector as (x, y, z) tuple."""


[docs] class Transform: """Transform represents a translate * rotate * scale (TRS) transform in that order, so scale is applied first, then rotate, and finally translate. Note that non-unit scale will typically not carry over into component and circuit placement, and while it can be used to compute a placement before it's applied, the results may be unexpected, and should be avoided. Also note that because the transforms are internally stored as decomposed values, a non-uniform scale cannot be applied to another transform, as this would result in a shear. Constructing a transform directly is typically only needed in niche cases, and more often than not it is better to use member methods of various objects (in JITX typically called :py:meth:`~jitx.shapes.Shape.at`) to transform the object. Supports the following operators: - ``t1 * t2`` : Compose transforms. Matrix order (T1 * T2 applies T2 then T1). - ``t * point`` : Apply transform to a Point, Vec2D, or Shape. - ``t * layer`` : Apply transform to a layer index, Side, or LayerSet. A plain transform carries no side, so these are returned unchanged; see :py:class:`~jitx.placement.Placement`. - ``~t`` : Compute the inverse transform. Args: translate: Translation as (x, y). rotate: Optional rotation angle in degrees. scale: Optional scale factors as (x, y). If a single value is provided, then the same value is used for both x and y. >>> # Place a component at (10, 20) with 90° rotation >>> xform = Transform((10, 20), rotate=90) >>> # Use helper methods instead >>> xform = Transform.translate(10, 20) * Transform.rotate(90) >>> # Apply transform to a point >>> point = (5, 0) >>> new_point = xform * point >>> print(new_point) (10, 25) >>> # Compose transforms >>> t1 = Transform.translate(10, 0) >>> t2 = Transform.rotate(90) >>> combined = t1 * t2 # Rotate first, then translate >>> # Transform a shape >>> circle = Circle(radius=5).at(0, 0) >>> moved_circle = t1 * circle """ __slots__ = ("_translate", "_rotate", "_scale") _translate: Point """Translation as (x, y).""" _rotate: float """Rotation angle in degrees.""" _scale: tuple[float, float] """Scale factors as (x_scale, y_scale).""" def __init__( self, translate: Point, rotate: float = 0, scale: float | tuple[float, float] = (1, 1), ): self._translate = translate self._rotate = rotate if not isinstance(scale, tuple): scale = (scale, scale) self._scale = scale def __repr__(self): return f"Transform({self._translate}, {self._rotate}, {self._scale})"
[docs] def clone(self): """Create a copy of this transform. Returns: New Transform with identical translation, rotation, and scale. """ return self.__class__(self._translate, self._rotate, self._scale)
@property def trs(self): """Get the transform components as a tuple (translation, rotation, scale).""" return self._translate, self._rotate, self._scale @property def translation(self) -> Point: """The transform's translation as (x, y).""" return self._translate @property def rotation(self) -> float: """The transform's rotation angle in degrees.""" return self._rotate def __eq__(self, other): return ( isinstance(other, Transform) and self._translate == other._translate and self._rotate == other._rotate and self._scale == other._scale ) @overload def __mul__[OTransform: Transform](self, other: OTransform) -> OTransform: ... @overload def __mul__(self, other: Point) -> Point: ... @overload def __mul__(self, other: Vec2D) -> Vec2D: ... @overload def __mul__[T: ShapeGeometry](self, other: Shape[T]) -> Shape[T]: ... @overload def __mul__(self, other: Side) -> Side: ... @overload def __mul__(self, other: int) -> int: ... @overload def __mul__(self, other: LayerSet) -> LayerSet: ... def __mul__( self, other: Transform | Point | Shape | Vec2D | int | LayerSet ) -> Transform | Point | Shape | Vec2D | int | LayerSet: # translate * rotate * scale * object if isinstance(other, int | LayerSet): # Side is an int, so this covers it as well. return other elif isinstance(other, Transform): return self.__apply_to_transform(other) elif isinstance(other, tuple): if len(other) == 2: return self.__apply_to_point(other) else: return NotImplemented elif isinstance(other, Vec2D): return self.__apply_to_vec2d(other) elif isinstance(other, Shape): if not other.transform: return Shape(other.geometry, self.clone()) else: return Shape(other.geometry, self.__apply_to_transform(other.transform)) else: return NotImplemented def __imul__(self, other: Transform) -> Self: xform = self.__apply_to_transform(other) self._translate = xform._translate self._rotate = xform._rotate self._scale = xform._scale return self def __apply_to_transform(self, xf: Transform) -> Transform: sx, sy = self._scale osx, osy = xf._scale r = xf._rotate if sx != sy and abs(sx) != abs(sy) and r != 0: raise ValueError( "Unable to apply a non-uniform scale to an existing transform" ) if sx * sy < 0: r = -r return self.__class__( self.__apply_to_point(xf._translate), self._rotate + r, (sx * osx, sy * osy), )._post_mul(self, xf) def _post_mul(self, left: Transform, right: Transform): return right._post_rmul(self, left) def _post_rmul(self, result: Transform, left: Transform): return result def __apply_to_point(self, pt: Point) -> Point: x, y = pt tx, ty = self._translate r = self._rotate sx, sy = self._scale if r < 0: r += 360 if r == 0: return (tx + sx * x, ty + sy * y) if r == 90: return (tx - sy * y, ty + sx * x) if r == 180: return (tx - sx * x, ty - sy * y) if r == 270: return (tx + sy * y, ty - sx * x) a = radians(r) cosa = cos(a) sina = sin(a) return (tx + sx * x * cosa - sy * y * sina, ty + sx * x * sina + sy * y * cosa) def __apply_to_vec2d(self, v: Vec2D) -> Point: # same as point, but without the translation x, y = v.x, v.y r = self._rotate sx, sy = self._scale if r < 0: r += 360 if r == 0: return (sx * x, +sy * y) if r == 90: return (-sy * y, +sx * x) if r == 180: return (-sx * x, -sy * y) if r == 270: return (+sy * y, -sx * x) a = radians(r) cosa = cos(a) sina = sin(a) return (sx * x * cosa - sy * y * sina, sx * x * sina + sy * y * cosa)
[docs] def inverse(self): """Compute the inverse transform. The inverse transform undoes the effect of this transform. Applying a transform followed by its inverse returns the original value. Returns: New Transform that is the inverse of this transform. >>> t = Transform.translate(10, 20) * Transform.rotate(45) >>> t_inv = t.inverse() >>> point = (5, 5) >>> # Applying transform then inverse returns original >>> result = t_inv * (t * point) >>> # result ≈ (5, 5) """ x, y = self._translate r = self._rotate sx, sy = self._scale return ( Transform.scale(1 / sx, 1 / sy) * Transform.rotate(-r) * Transform.translate(-x, -y) )
def __invert__(self): return self.inverse() @overload def matrix2x3( self, *, row_major: Literal[False] = False, flat: Literal[False] = False ) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]: ... @overload def matrix2x3( self, *, row_major: Literal[True], flat: Literal[False] = False ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: ... @overload def matrix2x3( self, *, row_major: bool = False, flat: Literal[True] ) -> tuple[float, float, float, float, float, float]: ...
[docs] def matrix2x3(self, *, row_major=False, flat=False): """Convert transform to a 2x3 affine transformation matrix. Returns the matrix representation of this transform, which can be used with graphics libraries or other systems that expect matrix form. Args: row_major: If True, return in row-major order; otherwise column-major (default). flat: If True, return as flat tuple; otherwise as nested tuples (default). Returns: Matrix in the requested format (nested tuples or flat tuple). >>> t = Transform.translate(10, 5) * Transform.rotate(90) >>> # Get column-major nested format (default) >>> m = t.matrix2x3() >>> # m = ((0, 1), (-1, 0), (10, 5)) >>> # Get row-major flat format >>> m = t.matrix2x3(row_major=True, flat=True) >>> # m = (0, -1, 10, 1, 0, 5) """ tx, ty = self._translate alpha = radians(self._rotate) ca = cos(alpha) sa = sin(alpha) sx, sy = self._scale m11 = ca * sx m21 = sa * sx m12 = -sa * sy m22 = ca * sy m13 = tx m23 = ty if not row_major: if flat: # fmt: off return ( m11, m21, m12, m22, m13, m23, ) # fmt: on else: # fmt: off return ( (m11, m21), (m12, m22), (m13, m23), ) # fmt: on else: if flat: # fmt: off return ( m11, m12, m13, m21, m22, m23, ) # fmt: on else: # fmt: off return ( (m11, m12, m13), (m21, m22, m23), )
# fmt: on @overload def matrix3x3( self, *, row_major: bool = False, flat: Literal[False] = False ) -> tuple[ tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], ]: ... @overload def matrix3x3( self, *, row_major: bool = False, flat: Literal[True] ) -> tuple[float, float, float, float, float, float, float, float, float]: ...
[docs] def matrix3x3(self, *, row_major=False, flat=False): """Convert transform to a 3x3 homogeneous transformation matrix. Returns the matrix representation as a 3x3 homogeneous matrix with the bottom row as [0, 0, 1], suitable for use with homogeneous coordinates. Args: row_major: If True, return in row-major order; otherwise column-major (default). flat: If True, return as flat tuple; otherwise as nested tuples (default). Returns: 3x3 matrix in the requested format (nested tuples or flat tuple). >>> t = Transform.translate(10, 5) >>> m = t.matrix3x3() >>> # Column-major: ((1, 0, 0), (0, 1, 0), (10, 5, 1)) """ m11, m21, m12, m22, m13, m23 = self.matrix2x3(flat=True) if not row_major: if flat: # fmt: off return ( m11, m21, 0.0, m12, m22, 0.0, m13, m23, 1.0, ) # fmt: on else: # fmt: off return ( (m11, m21, 0.0), (m12, m22, 0.0), (m13, m23, 1.0), ) # fmt: on else: if flat: # fmt: off return ( m11, m12, m13, m21, m22, m23, 0.0, 0.0, 1.0, ) # fmt: on else: # fmt: off return ( (m11, m12, m13), (m21, m22, m23), (0.0, 0.0, 1.0), )
# fmt: on @overload @classmethod def translate(cls, x: float, y: float, /) -> Self: ... @overload @classmethod def translate(cls, vector: Point, /) -> Self: ...
[docs] @classmethod def translate(cls, x: float | Point, y: float | None = None, /): """Create a translation-only transform. Args: x: X translation or a (x, y) point. y: Y translation (not used if x is a point). Returns: New Transform with only translation (no rotation or scaling). >>> # Translate by (10, 20) >>> t = Transform.translate(10, 20) >>> # Alternative using tuple >>> t = Transform.translate((10, 20)) >>> # Use in component placement >>> component.at(Transform.translate(5, 10)) """ if isinstance(x, tuple): return cls(x, 0, (1, 1)) else: assert isinstance(y, float | int) return cls((x, y), 0, (1, 1))
[docs] @classmethod def rotate(cls, angle: float): """Create a rotation-only transform. Args: angle: Rotation angle in degrees (counter-clockwise). Returns: New Transform with only rotation (no translation or scaling). >>> # Rotate 90 degrees counter-clockwise >>> t = Transform.rotate(90) >>> # Combine with translation >>> t = Transform.translate(10, 0) * Transform.rotate(45) """ return cls((0, 0), angle, (1, 1))
@overload @classmethod def scale(cls, x: float, y: float, /) -> Self: ... @overload @classmethod def scale(cls, uniform: float, /) -> Self: ...
[docs] @classmethod def scale(cls, x: float, y: float | None = None, /): """Create a scale-only transform. Args: x: X scale factor, or uniform scale if y is not provided. y: Y scale factor (optional, defaults to x for uniform scaling). Returns: New Transform with only scaling (no translation or rotation). Note: Non-uniform scaling should be used with caution as it may not carry over properly to component placement. >>> # Uniform scale by 2x >>> t = Transform.scale(2) >>> # Non-uniform scale >>> t = Transform.scale(2, 1.5) # 2x in X, 1.5x in Y """ if y is None: y = x return cls((0, 0), 0, (x, y))
[docs] @classmethod def identity(cls): """Create an identity transform (no transformation). Returns: New Transform that applies no transformation. >>> t = Transform.identity() >>> point = (5, 10) >>> result = t * point >>> # result == (5, 10) """ return cls((0, 0), 0, (1, 1))
[docs] class ImmutableTransform(Transform): __frozen = False def __init__( self, translate: Point, rotate: float = 0, scale: tuple[float, float] = (1, 1) ): super().__init__(translate, rotate, scale) self.__frozen = True def __setattr__(self, attr, value): if self.__frozen: raise ValueError("Transform is immutable") super().__setattr__(attr, value)
IDENTITY = ImmutableTransform.identity() """Immutable identity transform constant."""
[docs] class Vec2D: """A basic 2D vector class. Used internally for some calculations, not typically used in public APIs. Supports standard vector arithmetic operations: ``+``, ``-``, ``*`` (scalar), ``/`` (scalar), unary ``-``, and ``abs()`` (length). """ __slots__ = ("_x", "_y") _x: float _y: float def __init__(self, x: float, y: float): self._x = x self._y = y def __repr__(self): return f"Vec2D({self._x}, {self._y})" @overload def __add__(self, other: Vec2D) -> Vec2D: ... @overload def __add__(self, other: Point) -> Point: ... def __add__(self, other) -> Vec2D | Point: # vector + vector = vector # point + vector = point if isinstance(other, tuple): return (self._x + other[0], self._y + other[1]) elif isinstance(other, Vec2D): return Vec2D(self._x + other._x, self._y + other._y) else: return NotImplemented @overload def __radd__(self, other: Vec2D) -> Vec2D: ... @overload def __radd__(self, other: Point) -> Point: ... def __radd__(self, other) -> Vec2D | Point: return self.__add__(other) def __sub__(self, other) -> Vec2D: # vector - vector = vector # vector - point is undefined # point - point = vector, but can't be implemented since point is a tuple if isinstance(other, Vec2D): return Vec2D(self._x - other._x, self._y - other._y) else: return NotImplemented @overload def __rsub__(self, other: Vec2D) -> Vec2D: ... @overload def __rsub__(self, other: Point) -> Point: ... def __rsub__(self, other) -> Vec2D | Point: # point - vector = (-vector) + point = point return (-self).__add__(other) def __mul__(self, other: float) -> Vec2D: return Vec2D(self._x * other, self._y * other) def __rmul__(self, other: float) -> Vec2D: return Vec2D(self._x * other, self._y * other) def __truediv__(self, other: float) -> Vec2D: return Vec2D(self._x / other, self._y / other) def __floordiv__(self, other: float) -> Vec2D: return Vec2D(self._x // other, self._y // other) def __neg__(self) -> Vec2D: return Vec2D(-self._x, -self._y) def __pos__(self) -> Vec2D: return Vec2D(+self._x, +self._y) def __abs__(self) -> float: return sqrt(self._x**2 + self._y**2) def __getitem__(self, index: Literal[0, 1]) -> float: if index == 0: return self._x elif index == 1: return self._y else: raise IndexError("Vec2D index out of range") def __iter__(self) -> Iterator[float]: return iter((self._x, self._y)) def __eq__(self, other) -> bool: if not isinstance(other, Vec2D): return False return self._x == other._x and self._y == other._y def __lt__(self, other: Vec2D) -> bool: return self._x < other._x and self._y < other._y def __le__(self, other: Vec2D) -> bool: return self._x <= other._x and self._y <= other._y def __gt__(self, other: Vec2D) -> bool: return self._x > other._x and self._y > other._y def __ge__(self, other: Vec2D) -> bool: return self._x >= other._x and self._y >= other._y def __hash__(self) -> int: return hash((self._x, self._y)) @property def x(self) -> float: return self._x @property def y(self): return self._y @property def xy(self) -> tuple[float, float]: return self._x, self._y @property def length(self) -> float: return abs(self)
[docs] def normalized(self) -> Vec2D: return self / abs(self)
[docs] def dot(self, other: Vec2D) -> float: return self.x * other.x + self.y * other.y
[docs] def cross(self, other: Vec2D) -> float: """Compute the magnitude of the cross product.""" return self._x * other._y - self._y * other._x
[docs] def angle(self) -> float: """Compute the angle in radians counter-clockwise from the +X axis of this vector""" return atan2(self.y, self.x)
[docs] def transform_grid_point(xform: Transform, pt: GridPoint) -> GridPoint: """Transform a grid point by a transform. Grid points are integer coordinates used in schematic symbol positioning. This function ensures the result remains on the integer grid. Args: xform: Transform to apply. pt: Grid point as (x, y) integer tuple. Returns: Transformed grid point. Raises: ValueError: If the transformation results in non-integer coordinates. >>> t = Transform.translate(10, 5) >>> grid_pt = (3, 4) >>> new_pt = transform_grid_point(t, grid_pt) >>> # new_pt = (13, 9) """ x, y = xform * (pt[0], pt[1]) # Raise error if x or y is not an integer value if not x.is_integer() or not y.is_integer(): raise ValueError("Transform result is not a grid point") return (int(x), int(y))