# Copyright (c) 2010-2024, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Optional import math from ezdxf.math import Vec2, intersection_line_line_2d, UVec from .construct2d import is_point_left_of_line, TOLERANCE from .bbox import BoundingBox2d __all__ = ["ConstructionRay", "ConstructionLine", "ParallelRaysError"] class ParallelRaysError(ArithmeticError): pass HALF_PI = math.pi / 2.0 THREE_PI_HALF = 1.5 * math.pi DOUBLE_PI = math.pi * 2.0 ABS_TOL = 1e-12 class ConstructionRay: """Construction tool for infinite 2D rays. Args: p1: definition point 1 p2: ray direction as 2nd point or ``None`` angle: ray direction as angle in radians or ``None`` """ def __init__( self, p1: UVec, p2: Optional[UVec] = None, angle: Optional[float] = None ): self._location = Vec2(p1) self._angle: Optional[float] self._slope: Optional[float] self._yof0: Optional[float] self._direction: Vec2 self._is_vertical: bool self._is_horizontal: bool if p2 is not None: p2_ = Vec2(p2) if self._location.x < p2_.x: self._direction = (p2_ - self._location).normalize() else: self._direction = (self._location - p2_).normalize() self._angle = self._direction.angle elif angle is not None: self._angle = angle self._direction = Vec2.from_angle(angle) else: raise ValueError("p2 or angle required.") if abs(self._direction.x) <= ABS_TOL: self._slope = None self._yof0 = None else: self._slope = self._direction.y / self._direction.x self._yof0 = self._location.y - self._slope * self._location.x self._is_vertical = self._slope is None self._is_horizontal = abs(self._direction.y) <= ABS_TOL @property def location(self) -> Vec2: """Location vector as :class:`Vec2`.""" return self._location @property def direction(self) -> Vec2: """Direction vector as :class:`Vec2`.""" return self._direction @property def slope(self) -> Optional[float]: """Slope of ray or ``None`` if vertical.""" return self._slope @property def angle(self) -> float: """Angle between x-axis and ray in radians.""" if self._angle is None: return self._direction.angle else: return self._angle @property def angle_deg(self) -> float: """Angle between x-axis and ray in degrees.""" return math.degrees(self.angle) @property def is_vertical(self) -> bool: """``True`` if ray is vertical (parallel to y-axis).""" return self._is_vertical @property def is_horizontal(self) -> bool: """``True`` if ray is horizontal (parallel to x-axis).""" return self._is_horizontal def __repr__(self) -> str: return ( "ConstructionRay(p1=({0.location.x:.3f}, {0.location.y:.3f}), " "angle={0.angle:.5f})".format(self) ) def is_parallel(self, other: ConstructionRay) -> bool: """Returns ``True`` if rays are parallel.""" if self._is_vertical: return other._is_vertical if other._is_vertical: return False if self._is_horizontal: return other._is_horizontal # guards above guarantee that no slope is None return math.isclose(self._slope, other._slope, abs_tol=ABS_TOL) # type: ignore def intersect(self, other: ConstructionRay) -> Vec2: """Returns the intersection point as ``(x, y)`` tuple of `self` and `other`. Raises: ParallelRaysError: if rays are parallel """ ray1 = self ray2 = other if ray1.is_parallel(ray2): raise ParallelRaysError("Rays are parallel") if ray1._is_vertical: x = ray1._location.x if ray2.is_horizontal: y = ray2._location.y else: y = ray2.yof(x) elif ray2._is_vertical: x = ray2._location.x if ray1.is_horizontal: y = ray1._location.y else: y = ray1.yof(x) elif ray1._is_horizontal: y = ray1._location.y x = ray2.xof(y) elif ray2._is_horizontal: y = ray2._location.y x = ray1.xof(y) else: # calc intersection with the 'straight-line-equation' # based on y(x) = y0 + x*slope # guards above guarantee that no slope is None x = (ray1._yof0 - ray2._yof0) / (ray2._slope - ray1._slope) # type: ignore y = ray1.yof(x) return Vec2((x, y)) def orthogonal(self, location: UVec) -> ConstructionRay: """Returns orthogonal ray at `location`.""" return ConstructionRay(location, angle=self.angle + HALF_PI) def yof(self, x: float) -> float: """Returns y-value of ray for `x` location. Raises: ArithmeticError: for vertical rays """ if self._is_vertical: raise ArithmeticError # guard above guarantee that slope is not None return self._yof0 + float(x) * self._slope # type: ignore def xof(self, y: float) -> float: """Returns x-value of ray for `y` location. Raises: ArithmeticError: for horizontal rays """ if self._is_vertical: # slope == None return self._location.x elif not self._is_horizontal: # slope != None & slope != 0 return (float(y) - self._yof0) / self._slope # type: ignore else: raise ArithmeticError def bisectrix(self, other: ConstructionRay) -> ConstructionRay: """Bisectrix between `self` and `other`.""" intersection = self.intersect(other) alpha = (self.angle + other.angle) / 2.0 return ConstructionRay(intersection, angle=alpha) class ConstructionLine: """Construction tool for 2D lines. The :class:`ConstructionLine` class is similar to :class:`ConstructionRay`, but has a start- and endpoint. The direction of line goes from start- to endpoint, "left of line" is always in relation to this line direction. Args: start: start point of line as :class:`Vec2` compatible object end: end point of line as :class:`Vec2` compatible object """ def __init__(self, start: UVec, end: UVec): self.start = Vec2(start) self.end = Vec2(end) def __repr__(self) -> str: return "ConstructionLine({0.start}, {0.end})".format(self) @property def bounding_box(self) -> BoundingBox2d: """bounding box of line as :class:`BoundingBox2d` object.""" return BoundingBox2d((self.start, self.end)) def translate(self, dx: float, dy: float) -> None: """ Move line about `dx` in x-axis and about `dy` in y-axis. Args: dx: translation in x-axis dy: translation in y-axis """ v = Vec2(dx, dy) self.start += v self.end += v @property def sorted_points(self): return ( (self.end, self.start) if self.start > self.end else (self.start, self.end) ) @property def ray(self): """collinear :class:`ConstructionRay`.""" return ConstructionRay(self.start, self.end) def __eq__(self, other: object) -> bool: if not isinstance(other, ConstructionLine): raise TypeError(type(other)) return self.sorted_points == other.sorted_points def __lt__(self, other: object) -> bool: if not isinstance(other, ConstructionLine): raise TypeError(type(other)) return self.sorted_points < other.sorted_points def length(self) -> float: """Returns length of line.""" return (self.end - self.start).magnitude def midpoint(self) -> Vec2: """Returns mid point of line.""" return self.start.lerp(self.end) @property def is_vertical(self) -> bool: """``True`` if line is vertical.""" return math.isclose(self.start.x, self.end.x) @property def is_horizontal(self) -> bool: """``True`` if line is horizontal.""" return math.isclose(self.start.y, self.end.y) def inside_bounding_box(self, point: UVec) -> bool: """Returns ``True`` if `point` is inside of line bounding box.""" return self.bounding_box.inside(point) def intersect( self, other: ConstructionLine, abs_tol: float = TOLERANCE ) -> Optional[Vec2]: """Returns the intersection point of to lines or ``None`` if they have no intersection point. Args: other: other :class:`ConstructionLine` abs_tol: tolerance for distance check """ return intersection_line_line_2d( (self.start, self.end), (other.start, other.end), virtual=False, abs_tol=abs_tol, ) def has_intersection( self, other: ConstructionLine, abs_tol: float = TOLERANCE ) -> bool: """Returns ``True`` if has intersection with `other` line.""" return self.intersect(other, abs_tol=abs_tol) is not None def is_point_left_of_line(self, point: UVec, colinear=False) -> bool: """Returns ``True`` if `point` is left of construction line in relation to the line direction from start to end. If `colinear` is ``True``, a colinear point is also left of the line. """ return is_point_left_of_line( point, self.start, self.end, colinear=colinear )