Source code for skidl.geometry

# -*- coding: utf-8 -*-

# The MIT License (MIT) - Copyright (c) Dave Vandenbout.

from math import sqrt, sin, cos, radians
from copy import copy

from .utilities import export_to_all

__all__ = [
    "mms_per_mil",
    "mils_per_mm",
    "Vector",
    "tx_rot_0",
    "tx_rot_90",
    "tx_rot_180",
    "tx_rot_270",
    "tx_flip_x",
    "tx_flip_y",
]


"""
Stuff for handling geometry:
    transformation matrices,
    points,
    bounding boxes,
    line segments.
"""

# Millimeters/thousandths-of-inch conversion factor.
mils_per_mm = 39.37008
mms_per_mil = 0.0254


[docs] @export_to_all def to_mils(mm): """Convert millimeters to thousandths-of-inch and return.""" return mm * mils_per_mm
[docs] @export_to_all def to_mms(mils): """Convert thousandths-of-inch to millimeters and return.""" return mils * mms_per_mil
[docs] @export_to_all class Tx: def __init__(self, a=1, b=0, c=0, d=1, dx=0, dy=0): """Create a transformation matrix. tx = [ a b 0 c d 0 dx dy 1 ] x' = a*x + c*y + dx y' = b*x + d*y + dy """ self.a = a self.b = b self.c = c self.d = d self.dx = dx self.dy = dy
[docs] @classmethod def from_symtx(cls, symtx): """Return a Tx() object that implements the "HVLR" geometric operation sequence. Args: symtx (str): A string of H, V, L, R operations that are applied in sequence left-to-right. Returns: Tx: A transformation matrix that implements the sequence of geometric operations. """ op_dict = { "H": Tx(a=-1, c=0, b=0, d=1), # Horizontal flip. "V": Tx(a=1, c=0, b=0, d=-1), # Vertical flip. "L": Tx(a=0, c=-1, b=1, d=0), # Rotate 90 degrees left (counter-clockwise). "R": Tx(a=0, c=1, b=-1, d=0), # Rotate 90 degrees right (clockwise). } tx = Tx() for op in symtx.upper(): tx *= op_dict[op] return tx
def __repr__(self): return "{self.__class__}({self.a}, {self.b}, {self.c}, {self.d}, {self.dx}, {self.dy})".format( self=self ) def __str__(self): return "[{self.a}, {self.b}, {self.c}, {self.d}, {self.dx}, {self.dy}]".format( self=self ) def __mul__(self, m): """Return the product of two transformation matrices.""" if isinstance(m, Tx): tx = m else: # Assume m is a scalar, so convert it to a scaling Tx matrix. tx = Tx(a=m, d=m) return Tx( a=self.a * tx.a + self.b * tx.c, b=self.a * tx.b + self.b * tx.d, c=self.c * tx.a + self.d * tx.c, d=self.c * tx.b + self.d * tx.d, dx=self.dx * tx.a + self.dy * tx.c + tx.dx, dy=self.dx * tx.b + self.dy * tx.d + tx.dy, ) @property def origin(self): """Return the (dx, dy) translation as a Point.""" return Point(self.dx, self.dy) # This setter doesn't work in Python 2.7.18. # @origin.setter # def origin(self, pt): # """Set the (dx, dy) translation from an (x,y) Point.""" # self.dx, self.dy = pt.x, pt.y @property def scale(self): """Return the scaling factor.""" return (Point(1, 0) * self - Point(0, 0) * self).magnitude
[docs] def move(self, vec): """Return Tx with movement vector applied.""" return self * Tx(dx=vec.x, dy=vec.y)
[docs] def rot_90cw(self): """Return Tx with 90-deg clock-wise rotation around (0, 0).""" return self * Tx(a=0, b=1, c=-1, d=0)
[docs] def rot(self, degs): """Return Tx rotated by the given angle (in degrees).""" rads = radians(degs) return self * Tx(a=cos(rads), b=sin(rads), c=-sin(rads), d=cos(rads))
[docs] def flip_x(self): """Return Tx with X coords flipped around (0, 0).""" return self * Tx(a=-1)
[docs] def flip_y(self): """Return Tx with Y coords flipped around (0, 0).""" return self * Tx(d=-1)
[docs] def no_translate(self): """Return Tx with translation set to (0,0).""" return Tx(a=self.a, b=self.b, c=self.c, d=self.d)
# Some common rotations. tx_rot_0 = Tx(a=1, b=0, c=0, d=1) tx_rot_90 = Tx(a=0, b=1, c=-1, d=0) tx_rot_180 = Tx(a=-1, b=0, c=0, d=-1) tx_rot_270 = Tx(a=0, b=-1, c=1, d=0) # Some common flips. tx_flip_x = Tx(a=-1, b=0, c=0, d=1) tx_flip_y = Tx(a=1, b=0, c=0, d=-1)
[docs] @export_to_all class Point: def __init__(self, x, y): """Create a Point with coords x,y.""" self.x = x self.y = y def __hash__(self): """Return hash of X,Y tuple.""" return hash((self.x, self.y)) def __eq__(self, other): """Return true if (x,y) tuples of self and other are the same.""" return (self.x, self.y) == (other.x, other.y) def __lt__(self, other): """Return true if (x,y) tuple of self compares as less than (x,y) tuple of other.""" return (self.x, self.y) < (other.x, other.y) def __ne__(self, other): """Return true if (x,y) tuples of self and other differ.""" return not (self == other) def __add__(self, pt): """Add the x,y coords of pt to self and return the resulting Point.""" if not isinstance(pt, Point): pt = Point(pt, pt) return Point(self.x + pt.x, self.y + pt.y) def __sub__(self, pt): """Subtract the x,y coords of pt from self and return the resulting Point.""" if not isinstance(pt, Point): pt = Point(pt, pt) return Point(self.x - pt.x, self.y - pt.y) def __mul__(self, m): """Apply transformation matrix or scale factor to a point and return a point.""" if isinstance(m, Tx): return Point( self.x * m.a + self.y * m.c + m.dx, self.x * m.b + self.y * m.d + m.dy ) elif isinstance(m, Point): return Point(self.x * m.x, self.y * m.y) else: return Point(m * self.x, m * self.y) def __rmul__(self, m): if isinstance(m, Tx): raise ValueError else: return self * m
[docs] def xprod(self, pt): """Cross-product of two 2D vectors returns scalar in Z coord.""" return self.x * pt.y - self.y * pt.x
[docs] def mask(self, msk): """Multiply the X & Y coords by the elements of msk.""" return Point(self.x * msk[0], self.y * msk[1])
def __neg__(self): """Negate both coords.""" return Point(-self.x, -self.y) def __truediv__(self, d): """Divide the x,y coords by d.""" return Point(self.x / d, self.y / d) def __div__(self, d): """Divide the x,y coords by d.""" return Point(self.x / d, self.y / d)
[docs] def round(self): return Point(int(round(self.x)), int(round(self.y)))
def __str__(self): return "{} {}".format(self.x, self.y)
[docs] def snap(self, grid_spacing): """Snap point x,y coords to the given grid spacing.""" snap_func = lambda x: int(grid_spacing * round(x / grid_spacing)) return Point(snap_func(self.x), snap_func(self.y))
[docs] def min(self, pt): """Return a Point with coords that are the min x,y of both points.""" return Point(min(self.x, pt.x), min(self.y, pt.y))
[docs] def max(self, pt): """Return a Point with coords that are the max x,y of both points.""" return Point(max(self.x, pt.x), max(self.y, pt.y))
@property def magnitude(self): """Get the distance of the point from the origin.""" return sqrt(self.x**2 + self.y**2) @property def norm(self): """Return a unit vector pointing from the origin to the point.""" try: return self / self.magnitude except ZeroDivisionError: return Point(0, 0)
[docs] def flip_xy(self): """Flip X-Y coordinates of point.""" self.x, self.y = self.y, self.x
def __repr__(self): return "{self.__class__}({self.x}, {self.y})".format(self=self) def __str__(self): return "({}, {})".format(self.x, self.y)
Vector = Point
[docs] @export_to_all class BBox: def __init__(self, *pts): """Create a bounding box surrounding the given points.""" inf = float("inf") self.min = Point(inf, inf) self.max = Point(-inf, -inf) self.add(*pts) def __add__(self, obj): """Return the merged BBox of two BBoxes or a BBox and a Point.""" sum_ = BBox() if isinstance(obj, Point): sum_.min = self.min.min(obj) sum_.max = self.max.max(obj) elif isinstance(obj, BBox): sum_.min = self.min.min(obj.min) sum_.max = self.max.max(obj.max) else: raise NotImplementedError return sum_ def __iadd__(self, obj): """Update BBox bt adding another Point or BBox""" sum_ = self + obj self.min = sum_.min self.max = sum_.max return self
[docs] def add(self, *objs): """Update the bounding box size by adding Point/BBox objects.""" for obj in objs: self += obj return self
def __mul__(self, m): return BBox(self.min * m, self.max * m)
[docs] def round(self): return BBox(self.min.round(), self.max.round())
[docs] def is_inside(self, pt): """Return True if point is inside bounding box.""" return (self.min.x <= pt.x <= self.max.x) and (self.min.y <= pt.y <= self.max.y)
[docs] def intersects(self, bbox): """Return True if the two bounding boxes intersect.""" return ( (self.min.x < bbox.max.x) and (self.max.x > bbox.min.x) and (self.min.y < bbox.max.y) and (self.max.y > bbox.min.y) )
[docs] def intersection(self, bbox): """Return the bounding box of the intersection between the two bounding boxes.""" if not self.intersects(bbox): return None corner1 = self.min.max(bbox.min) corner2 = self.max.min(bbox.max) return BBox(corner1, corner2)
[docs] def resize(self, vector): """Expand/contract the bounding box by applying vector to its corner points.""" return BBox(self.min - vector, self.max + vector)
[docs] def snap_resize(self, grid_spacing): """Resize bbox so max and min points are on grid. Args: grid_spacing (float): Grid spacing. """ bbox = self.resize(Point(grid_spacing - 1, grid_spacing - 1)) bbox.min = bbox.min.snap(grid_spacing) bbox.max = bbox.max.snap(grid_spacing) return bbox
@property def area(self): """Return area of bounding box.""" return self.w * self.h @property def w(self): """Return the bounding box width.""" return abs(self.max.x - self.min.x) @property def h(self): """Return the bounding box height.""" return abs(self.max.y - self.min.y) @property def ctr(self): """Return center point of bounding box.""" return (self.max + self.min) / 2 @property def ll(self): """Return lower-left point of bounding box.""" return Point(self.min.x, self.min.y) @property def lr(self): """Return lower-right point of bounding box.""" return Point(self.max.x, self.min.y) @property def ul(self): """Return upper-left point of bounding box.""" return Point(self.min.x, self.max.y) @property def ur(self): """Return upper-right point of bounding box.""" return Point(self.max.x, self.max.y) def __repr__(self): return "{self.__class__}(Point({self.min}), Point({self.max}))".format( self=self ) def __str__(self): return "[{}, {}]".format(self.min, self.max)
[docs] @export_to_all class Segment: def __init__(self, p1, p2): "Create a line segment between two points." self.p1 = copy(p1) self.p2 = copy(p2) def __mul__(self, m): """Apply transformation matrix to a segment and return a segment.""" return Segment(self.p1 * m, self.p2 * m)
[docs] def round(self): return Segment(self.p1.round(), self.p2.round())
def __str__(self): return "{} {}".format(str(self.p1), str(self.p2))
[docs] def flip_xy(self): """Flip the X-Y coordinates of the segment.""" self.p1.flip_xy() self.p2.flip_xy()
[docs] def intersects(self, other): """Return true if the segments intersect.""" # FIXME: This fails if the segments are parallel! raise NotImplementedError # Given two segments: # self: p1 + (p2-p1) * t1 # other: p3 + (p4-p3) * t2 # Look for a solution t1, t2 that solves: # p1x + (p2x-p1x)*t1 = p3x + (p4x-p3x)*t2 # p1y + (p2y-p1y)*t1 = p3y + (p4y-p3y)*t2 # If t1 and t2 are both in range [0,1], then the two segments intersect. p1x, p1y, p2x, p2y = self.p1.x, self.p1.y, self.p2.x, self.p2.y p3x, p3y, p4x, p4y = other.p1.x, other.p1.y, other.p2.x, other.p2.y # denom = p1x*p3y - p1x*p4y - p1y*p3x + p1y*p4x - p2x*p3y + p2x*p4y + p2y*p3x - p2y*p4x # denom = p1x * (p3y - p4y) + p1y * (p4x - p3x) + p2x * (p4y - p3y) + p2y * (p3x - p4x) denom = (p1x - p2x) * (p3y - p4y) + (p1y - p2y) * (p4x - p3x) try: # t1 = (p1x*p3y - p1x*p4y - p1y*p3x + p1y*p4x + p3x*p4y - p3y*p4x) / denom # t2 = (-p1x*p2y + p1x*p3y + p1y*p2x - p1y*p3x - p2x*p3y + p2y*p3x) / denom t1 = ((p1y - p3y) * (p4x - p3x) - (p1x - p3x) * (p4y - p3y)) / denom t2 = ((p1y - p3y) * (p2x - p3x) - (p1x - p3x) * (p2y - p3y)) / denom except ZeroDivisionError: return False return (0 <= t1 <= 1) and (0 <= t2 <= 1)
[docs] def shadows(self, other): """Return true if two segments overlap each other even if they aren't on the same horiz or vertical track.""" if self.p1.x == self.p2.x and other.p1.x == other.p2.x: # Horizontal segments. See if their vertical extents overlap. self_min = min(self.p1.y, self.p2.y) self_max = max(self.p1.y, self.p2.y) other_min = min(other.p1.y, other.p2.y) other_max = max(other.p1.y, other.p2.y) elif self.p1.y == self.p2.y and other.p1.y == other.p2.y: # Verttical segments. See if their horizontal extents overlap. self_min = min(self.p1.x, self.p2.x) self_max = max(self.p1.x, self.p2.x) other_min = min(other.p1.x, other.p2.x) other_max = max(other.p1.x, other.p2.x) else: # Segments aren't horizontal or vertical, so neither can shadow the other. return False # Overlap conditions based on segment endpoints. return other_min < self_max and other_max > self_min