refactor: excel parse
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# Copyright (c) 2011-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Iterable
|
||||
# Using * imports to simplify namespace imports, therefore every module
|
||||
# has to have an export declaration: __all__ = [...]
|
||||
|
||||
# Import base types as C-extensions if available:
|
||||
from ._ctypes import *
|
||||
# Everything else are pure Python imports:
|
||||
from .construct2d import *
|
||||
from .construct3d import *
|
||||
from .parametrize import *
|
||||
from .bspline import *
|
||||
from .bezier import *
|
||||
from .bezier_interpolation import *
|
||||
from .eulerspiral import *
|
||||
from .ucs import *
|
||||
from .bulge import *
|
||||
from .arc import *
|
||||
from .line import *
|
||||
from .circle import *
|
||||
from .ellipse import *
|
||||
from .box import *
|
||||
from .shape import *
|
||||
from .bbox import *
|
||||
from .offset2d import *
|
||||
from .transformtools import *
|
||||
from .curvetools import *
|
||||
from .polyline import *
|
||||
|
||||
ABS_TOL = 1e-12
|
||||
REL_TOL = 1e-9
|
||||
|
||||
|
||||
def close_vectors(a: Iterable[AnyVec], b: Iterable[UVec], *,
|
||||
rel_tol=REL_TOL, abs_tol=ABS_TOL) -> bool:
|
||||
return all(v1.isclose(v2, rel_tol=rel_tol, abs_tol=abs_tol)
|
||||
for v1, v2 in zip(a, b))
|
||||
|
||||
|
||||
def xround(value: float, rounding: float = 0.) -> float:
|
||||
"""Extended rounding function.
|
||||
|
||||
The argument `rounding` defines the rounding limit:
|
||||
|
||||
======= ======================================
|
||||
0 remove fraction
|
||||
0.1 round next to x.1, x.2, ... x.0
|
||||
0.25 round next to x.25, x.50, x.75 or x.00
|
||||
0.5 round next to x.5 or x.0
|
||||
1.0 round to a multiple of 1: remove fraction
|
||||
2.0 round to a multiple of 2: xxx2, xxx4, xxx6 ...
|
||||
5.0 round to a multiple of 5: xxx5 or xxx0
|
||||
10.0 round to a multiple of 10: xx10, xx20, ...
|
||||
======= ======================================
|
||||
|
||||
Args:
|
||||
value: float value to round
|
||||
rounding: rounding limit
|
||||
|
||||
"""
|
||||
if rounding == 0:
|
||||
return round(value)
|
||||
factor = 1. / rounding
|
||||
return round(value * factor) / factor
|
||||
@@ -0,0 +1,200 @@
|
||||
# Copyright (c) 2021-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
# pylint: disable=unused-variable
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterator,
|
||||
Sequence,
|
||||
Optional,
|
||||
Generic,
|
||||
TypeVar,
|
||||
)
|
||||
import math
|
||||
|
||||
# The pure Python implementation can't import from ._ctypes or ezdxf.math!
|
||||
from ._vector import Vec3, Vec2
|
||||
from ._matrix44 import Matrix44
|
||||
|
||||
|
||||
__all__ = ["Bezier3P"]
|
||||
|
||||
|
||||
def check_if_in_valid_range(t: float) -> None:
|
||||
if not 0.0 <= t <= 1.0:
|
||||
raise ValueError("t not in range [0 to 1]")
|
||||
|
||||
|
||||
T = TypeVar("T", Vec2, Vec3)
|
||||
|
||||
|
||||
class Bezier3P(Generic[T]):
|
||||
"""Implements an optimized quadratic `Bézier curve`_ for exact 3 control
|
||||
points.
|
||||
|
||||
The class supports points of type :class:`Vec2` and :class:`Vec3` as input, the
|
||||
class instances are immutable.
|
||||
|
||||
Args:
|
||||
defpoints: sequence of definition points as :class:`Vec2` or
|
||||
:class:`Vec3` compatible objects.
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("_control_points", "_offset")
|
||||
|
||||
def __init__(self, defpoints: Sequence[T]):
|
||||
if len(defpoints) != 3:
|
||||
raise ValueError("Three control points required.")
|
||||
point_type = defpoints[0].__class__
|
||||
if not point_type.__name__ in ("Vec2", "Vec3"): # Cython types!!!
|
||||
raise TypeError(f"invalid point type: {point_type.__name__}")
|
||||
|
||||
# The start point is the curve offset
|
||||
offset: T = defpoints[0]
|
||||
self._offset: T = offset
|
||||
# moving the curve to the origin reduces floating point errors:
|
||||
self._control_points: tuple[T, ...] = tuple(p - offset for p in defpoints)
|
||||
|
||||
@property
|
||||
def control_points(self) -> Sequence[T]:
|
||||
"""Control points as tuple of :class:`Vec3` or :class:`Vec2` objects."""
|
||||
# ezdxf optimization: p0 is always (0, 0, 0)
|
||||
_, p1, p2 = self._control_points
|
||||
offset = self._offset
|
||||
return offset, p1 + offset, p2 + offset
|
||||
|
||||
def tangent(self, t: float) -> T:
|
||||
"""Returns direction vector of tangent for location `t` at the
|
||||
Bèzier-curve.
|
||||
|
||||
Args:
|
||||
t: curve position in the range ``[0, 1]``
|
||||
|
||||
"""
|
||||
check_if_in_valid_range(t)
|
||||
return self._get_curve_tangent(t)
|
||||
|
||||
def point(self, t: float) -> T:
|
||||
"""Returns point for location `t` at the Bèzier-curve.
|
||||
|
||||
Args:
|
||||
t: curve position in the range ``[0, 1]``
|
||||
|
||||
"""
|
||||
check_if_in_valid_range(t)
|
||||
return self._get_curve_point(t)
|
||||
|
||||
def approximate(self, segments: int) -> Iterator[T]:
|
||||
"""Approximate `Bézier curve`_ by vertices, yields `segments` + 1
|
||||
vertices as ``(x, y[, z])`` tuples.
|
||||
|
||||
Args:
|
||||
segments: count of segments for approximation
|
||||
|
||||
"""
|
||||
if segments < 1:
|
||||
raise ValueError(segments)
|
||||
delta_t: float = 1.0 / segments
|
||||
cp = self.control_points
|
||||
yield cp[0]
|
||||
for segment in range(1, segments):
|
||||
yield self._get_curve_point(delta_t * segment)
|
||||
yield cp[2]
|
||||
|
||||
def approximated_length(self, segments: int = 128) -> float:
|
||||
"""Returns estimated length of Bèzier-curve as approximation by line
|
||||
`segments`.
|
||||
"""
|
||||
length: float = 0.0
|
||||
prev_point: Optional[T] = None
|
||||
for point in self.approximate(segments):
|
||||
if prev_point is not None:
|
||||
length += prev_point.distance(point)
|
||||
prev_point = point
|
||||
return length
|
||||
|
||||
def flattening(self, distance: float, segments: int = 4) -> Iterator[T]:
|
||||
"""Adaptive recursive flattening. The argument `segments` is the
|
||||
minimum count of approximation segments, if the distance from the center
|
||||
of the approximation segment to the curve is bigger than `distance` the
|
||||
segment will be subdivided.
|
||||
|
||||
Args:
|
||||
distance: maximum distance from the center of the quadratic (C2)
|
||||
curve to the center of the linear (C1) curve between two
|
||||
approximation points to determine if a segment should be
|
||||
subdivided.
|
||||
segments: minimum segment count
|
||||
|
||||
"""
|
||||
stack: list[tuple[float, T]] = []
|
||||
dt: float = 1.0 / segments
|
||||
t0: float = 0.0
|
||||
t1: float
|
||||
cp = self.control_points
|
||||
start_point: T = cp[0]
|
||||
end_point: T
|
||||
|
||||
yield start_point
|
||||
while t0 < 1.0:
|
||||
t1 = t0 + dt
|
||||
if math.isclose(t1, 1.0):
|
||||
end_point = cp[2]
|
||||
t1 = 1.0
|
||||
else:
|
||||
end_point = self._get_curve_point(t1)
|
||||
|
||||
while True:
|
||||
mid_t: float = (t0 + t1) * 0.5
|
||||
mid_point: T = self._get_curve_point(mid_t)
|
||||
chk_point: T = start_point.lerp(end_point)
|
||||
|
||||
d = chk_point.distance(mid_point)
|
||||
if d < distance:
|
||||
yield end_point
|
||||
t0 = t1
|
||||
start_point = end_point
|
||||
if stack:
|
||||
t1, end_point = stack.pop()
|
||||
else:
|
||||
break
|
||||
else:
|
||||
stack.append((t1, end_point))
|
||||
t1 = mid_t
|
||||
end_point = mid_point
|
||||
|
||||
def _get_curve_point(self, t: float) -> T:
|
||||
# 1st control point (p0) is always (0, 0, 0)
|
||||
# => p0 * a is always (0, 0, 0)
|
||||
_, p1, p2 = self._control_points
|
||||
_1_minus_t = 1.0 - t
|
||||
# a = (1 - t) ** 2
|
||||
b = 2.0 * t * _1_minus_t
|
||||
c = t * t
|
||||
# add offset at last - it is maybe very large
|
||||
return p1 * b + p2 * c + self._offset
|
||||
|
||||
def _get_curve_tangent(self, t: float) -> T:
|
||||
# tangent vector is independent from offset location!
|
||||
# 1st control point (p0) is always (0, 0, 0)
|
||||
# => p0 * a is always (0, 0, 0)
|
||||
_, p1, p2 = self._control_points
|
||||
# a = -2 * (1 - t)
|
||||
b = 2.0 - 4.0 * t
|
||||
c = 2.0 * t
|
||||
return p1 * b + p2 * c
|
||||
|
||||
def reverse(self) -> Bezier3P[T]:
|
||||
"""Returns a new Bèzier-curve with reversed control point order."""
|
||||
return Bezier3P(list(reversed(self.control_points)))
|
||||
|
||||
def transform(self, m: Matrix44) -> Bezier3P[Vec3]:
|
||||
"""General transformation interface, returns a new :class:`Bezier3P`
|
||||
curve and it is always a 3D curve.
|
||||
|
||||
Args:
|
||||
m: 4x4 transformation :class:`Matrix44`
|
||||
|
||||
"""
|
||||
defpoints = Vec3.generate(self.control_points)
|
||||
return Bezier3P(tuple(m.transform_vertices(defpoints)))
|
||||
@@ -0,0 +1,336 @@
|
||||
# Copyright (c) 2010-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
Generic,
|
||||
)
|
||||
import math
|
||||
|
||||
# The pure Python implementation can't import from ._ctypes or ezdxf.math!
|
||||
from ._vector import Vec3, Vec2
|
||||
from ._matrix44 import Matrix44
|
||||
from ._construct import arc_angle_span_deg
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.math import UVec
|
||||
from ezdxf.math.ellipse import ConstructionEllipse
|
||||
|
||||
__all__ = [
|
||||
"Bezier4P",
|
||||
"cubic_bezier_arc_parameters",
|
||||
"cubic_bezier_from_arc",
|
||||
"cubic_bezier_from_ellipse",
|
||||
]
|
||||
|
||||
T = TypeVar("T", Vec2, Vec3)
|
||||
|
||||
|
||||
class Bezier4P(Generic[T]):
|
||||
"""Implements an optimized cubic `Bézier curve`_ for exact 4 control points.
|
||||
|
||||
A `Bézier curve`_ is a parametric curve, parameter `t` goes from 0 to 1,
|
||||
where 0 is the first control point and 1 is the fourth control point.
|
||||
|
||||
The class supports points of type :class:`Vec2` and :class:`Vec3` as input, the
|
||||
class instances are immutable.
|
||||
|
||||
Args:
|
||||
defpoints: sequence of definition points as :class:`Vec2` or
|
||||
:class:`Vec3` compatible objects.
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("_control_points", "_offset")
|
||||
|
||||
def __init__(self, defpoints: Sequence[T]):
|
||||
if len(defpoints) != 4:
|
||||
raise ValueError("Four control points required.")
|
||||
point_type = defpoints[0].__class__
|
||||
if not point_type.__name__ in ("Vec2", "Vec3"): # Cython types!!!
|
||||
raise TypeError(f"invalid point type: {point_type.__name__}")
|
||||
|
||||
# The start point is the curve offset
|
||||
offset: T = defpoints[0]
|
||||
self._offset: T = offset
|
||||
# moving the curve to the origin reduces floating point errors:
|
||||
self._control_points: tuple[T, ...] = tuple(p - offset for p in defpoints)
|
||||
|
||||
@property
|
||||
def control_points(self) -> Sequence[T]:
|
||||
"""Control points as tuple of :class:`Vec3` or :class:`Vec2` objects."""
|
||||
# ezdxf optimization: p0 is always (0, 0, 0)
|
||||
p0, p1, p2, p3 = self._control_points
|
||||
offset = self._offset
|
||||
return offset, p1 + offset, p2 + offset, p3 + offset
|
||||
|
||||
def tangent(self, t: float) -> T:
|
||||
"""Returns direction vector of tangent for location `t` at the
|
||||
Bèzier-curve.
|
||||
|
||||
Args:
|
||||
t: curve position in the range ``[0, 1]``
|
||||
|
||||
"""
|
||||
if not (0 <= t <= 1.0):
|
||||
raise ValueError("t not in range [0 to 1]")
|
||||
return self._get_curve_tangent(t)
|
||||
|
||||
def point(self, t: float) -> T:
|
||||
"""Returns point for location `t` at the Bèzier-curve.
|
||||
|
||||
Args:
|
||||
t: curve position in the range ``[0, 1]``
|
||||
|
||||
"""
|
||||
if not (0 <= t <= 1.0):
|
||||
raise ValueError("t not in range [0 to 1]")
|
||||
return self._get_curve_point(t)
|
||||
|
||||
def approximate(self, segments: int) -> Iterator[T]:
|
||||
"""Approximate `Bézier curve`_ by vertices, yields `segments` + 1
|
||||
vertices as ``(x, y[, z])`` tuples.
|
||||
|
||||
Args:
|
||||
segments: count of segments for approximation
|
||||
|
||||
"""
|
||||
if segments < 1:
|
||||
raise ValueError(segments)
|
||||
delta_t = 1.0 / segments
|
||||
cp = self.control_points
|
||||
yield cp[0]
|
||||
for segment in range(1, segments):
|
||||
yield self._get_curve_point(delta_t * segment)
|
||||
yield cp[3]
|
||||
|
||||
def flattening(self, distance: float, segments: int = 4) -> Iterator[T]:
|
||||
"""Adaptive recursive flattening. The argument `segments` is the
|
||||
minimum count of approximation segments, if the distance from the center
|
||||
of the approximation segment to the curve is bigger than `distance` the
|
||||
segment will be subdivided.
|
||||
|
||||
Args:
|
||||
distance: maximum distance from the center of the cubic (C3)
|
||||
curve to the center of the linear (C1) curve between two
|
||||
approximation points to determine if a segment should be
|
||||
subdivided.
|
||||
segments: minimum segment count
|
||||
|
||||
"""
|
||||
stack: list[tuple[float, T]] = []
|
||||
dt: float = 1.0 / segments
|
||||
t0: float = 0.0
|
||||
t1: float
|
||||
cp = self.control_points
|
||||
start_point: T = cp[0]
|
||||
end_point: T
|
||||
|
||||
yield start_point
|
||||
while t0 < 1.0:
|
||||
t1 = t0 + dt
|
||||
if math.isclose(t1, 1.0):
|
||||
end_point = cp[3]
|
||||
t1 = 1.0
|
||||
else:
|
||||
end_point = self._get_curve_point(t1)
|
||||
|
||||
while True:
|
||||
mid_t: float = (t0 + t1) * 0.5
|
||||
mid_point: T = self._get_curve_point(mid_t)
|
||||
chk_point: T = start_point.lerp(end_point)
|
||||
|
||||
d = chk_point.distance(mid_point)
|
||||
if d < distance:
|
||||
yield end_point
|
||||
t0 = t1
|
||||
start_point = end_point
|
||||
if stack:
|
||||
t1, end_point = stack.pop()
|
||||
else:
|
||||
break
|
||||
else:
|
||||
stack.append((t1, end_point))
|
||||
t1 = mid_t
|
||||
end_point = mid_point
|
||||
|
||||
def _get_curve_point(self, t: float) -> T:
|
||||
# 1st control point (p0) is always (0, 0, 0)
|
||||
# => p0 * a is always (0, 0, 0)
|
||||
# add offset at last - it is maybe very large
|
||||
_, p1, p2, p3 = self._control_points
|
||||
t2 = t * t
|
||||
_1_minus_t = 1.0 - t
|
||||
# a = _1_minus_t_square * _1_minus_t
|
||||
b = 3.0 * _1_minus_t * _1_minus_t * t
|
||||
c = 3.0 * _1_minus_t * t2
|
||||
d = t2 * t
|
||||
return p1 * b + p2 * c + p3 * d + self._offset
|
||||
|
||||
def _get_curve_tangent(self, t: float) -> T:
|
||||
# tangent vector is independent from offset location!
|
||||
# 1st control point (p0) is always (0, 0, 0)
|
||||
# => p0 * a is always (0, 0, 0)
|
||||
_, p1, p2, p3 = self._control_points
|
||||
t2 = t * t
|
||||
# a = -3.0 * (1.0 - t) ** 2
|
||||
b = 3.0 * (1.0 - 4.0 * t + 3.0 * t2)
|
||||
c = 3.0 * t * (2.0 - 3.0 * t)
|
||||
d = 3.0 * t2
|
||||
return p1 * b + p2 * c + p3 * d
|
||||
|
||||
def approximated_length(self, segments: int = 128) -> float:
|
||||
"""Returns estimated length of Bèzier-curve as approximation by line
|
||||
`segments`.
|
||||
"""
|
||||
length = 0.0
|
||||
prev_point = None
|
||||
for point in self.approximate(segments):
|
||||
if prev_point is not None:
|
||||
length += prev_point.distance(point)
|
||||
prev_point = point
|
||||
return length
|
||||
|
||||
def reverse(self) -> Bezier4P[T]:
|
||||
"""Returns a new Bèzier-curve with reversed control point order."""
|
||||
return Bezier4P(list(reversed(self.control_points)))
|
||||
|
||||
def transform(self, m: Matrix44) -> Bezier4P[Vec3]:
|
||||
"""General transformation interface, returns a new :class:`Bezier4p`
|
||||
curve as a 3D curve.
|
||||
|
||||
Args:
|
||||
m: 4x4 transformation :class:`Matrix44`
|
||||
|
||||
"""
|
||||
defpoints = Vec3.generate(self.control_points)
|
||||
return Bezier4P(tuple(m.transform_vertices(defpoints)))
|
||||
|
||||
|
||||
def cubic_bezier_from_arc(
|
||||
center: UVec = (0, 0, 0),
|
||||
radius: float = 1,
|
||||
start_angle: float = 0,
|
||||
end_angle: float = 360,
|
||||
segments: int = 1,
|
||||
) -> Iterator[Bezier4P[Vec3]]:
|
||||
"""Returns an approximation for a circular 2D arc by multiple cubic
|
||||
Bézier-curves.
|
||||
|
||||
Args:
|
||||
center: circle center as :class:`Vec3` compatible object
|
||||
radius: circle radius
|
||||
start_angle: start angle in degrees
|
||||
end_angle: end angle in degrees
|
||||
segments: count of Bèzier-curve segments, at least one segment for each
|
||||
quarter (90 deg), 1 for as few as possible.
|
||||
|
||||
"""
|
||||
center_: Vec3 = Vec3(center)
|
||||
radius = float(radius)
|
||||
angle_span: float = arc_angle_span_deg(start_angle, end_angle)
|
||||
if abs(angle_span) < 1e-9:
|
||||
return
|
||||
|
||||
s: float = start_angle
|
||||
start_angle = math.radians(s) % math.tau
|
||||
end_angle = math.radians(s + angle_span)
|
||||
while start_angle > end_angle:
|
||||
end_angle += math.tau
|
||||
|
||||
for control_points in cubic_bezier_arc_parameters(start_angle, end_angle, segments):
|
||||
defpoints = [center_ + (p * radius) for p in control_points]
|
||||
yield Bezier4P(defpoints)
|
||||
|
||||
|
||||
PI_2: float = math.pi / 2.0
|
||||
|
||||
|
||||
def cubic_bezier_from_ellipse(
|
||||
ellipse: "ConstructionEllipse", segments: int = 1
|
||||
) -> Iterator[Bezier4P[Vec3]]:
|
||||
"""Returns an approximation for an elliptic arc by multiple cubic
|
||||
Bézier-curves.
|
||||
|
||||
Args:
|
||||
ellipse: ellipse parameters as :class:`~ezdxf.math.ConstructionEllipse`
|
||||
object
|
||||
segments: count of Bèzier-curve segments, at least one segment for each
|
||||
quarter (π/2), 1 for as few as possible.
|
||||
|
||||
"""
|
||||
param_span: float = ellipse.param_span
|
||||
if abs(param_span) < 1e-9:
|
||||
return
|
||||
start_angle: float = ellipse.start_param % math.tau
|
||||
end_angle: float = start_angle + param_span
|
||||
while start_angle > end_angle:
|
||||
end_angle += math.tau
|
||||
|
||||
def transform(points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
center = Vec3(ellipse.center)
|
||||
x_axis: Vec3 = ellipse.major_axis
|
||||
y_axis: Vec3 = ellipse.minor_axis
|
||||
for p in points:
|
||||
yield center + x_axis * p.x + y_axis * p.y
|
||||
|
||||
for defpoints in cubic_bezier_arc_parameters(start_angle, end_angle, segments):
|
||||
yield Bezier4P(tuple(transform(defpoints)))
|
||||
|
||||
|
||||
# Circular arc to Bezier curve:
|
||||
# Source: https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves
|
||||
# Optimization: https://spencermortensen.com/articles/bezier-circle/
|
||||
# actual c = 0.5522847498307935 = 4.0/3.0*(sqrt(2)-1.0) and max. deviation of ~0.03%
|
||||
DEFAULT_TANGENT_FACTOR = 4.0 / 3.0 # 1.333333333333333333
|
||||
# optimal c = 0.551915024494 and max. deviation of ~0.02%
|
||||
OPTIMIZED_TANGENT_FACTOR = 1.3324407374108935
|
||||
# Not sure if this is the correct way to apply this optimization,
|
||||
# so i stick to the original version for now:
|
||||
TANGENT_FACTOR = DEFAULT_TANGENT_FACTOR
|
||||
|
||||
|
||||
def cubic_bezier_arc_parameters(
|
||||
start_angle: float, end_angle: float, segments: int = 1
|
||||
) -> Iterator[tuple[Vec3, Vec3, Vec3, Vec3]]:
|
||||
"""Yields cubic Bézier-curve parameters for a circular 2D arc with center
|
||||
at (0, 0) and a radius of 1 in the form of [start point, 1. control point,
|
||||
2. control point, end point].
|
||||
|
||||
Args:
|
||||
start_angle: start angle in radians
|
||||
end_angle: end angle in radians (end_angle > start_angle!)
|
||||
segments: count of Bèzier-curve segments, at least one segment for each
|
||||
quarter (π/2)
|
||||
|
||||
"""
|
||||
if segments < 1:
|
||||
raise ValueError("Invalid argument segments (>= 1).")
|
||||
delta_angle: float = end_angle - start_angle
|
||||
if delta_angle > 0:
|
||||
arc_count = max(math.ceil(delta_angle / math.pi * 2.0), segments)
|
||||
else:
|
||||
raise ValueError("Delta angle from start- to end angle has to be > 0.")
|
||||
|
||||
segment_angle: float = delta_angle / arc_count
|
||||
tangent_length: float = TANGENT_FACTOR * math.tan(segment_angle / 4.0)
|
||||
|
||||
angle: float = start_angle
|
||||
end_point: Vec3 = Vec3.from_angle(angle)
|
||||
for _ in range(arc_count):
|
||||
start_point = end_point
|
||||
angle += segment_angle
|
||||
end_point = Vec3.from_angle(angle)
|
||||
control_point_1 = start_point + (
|
||||
-start_point.y * tangent_length,
|
||||
start_point.x * tangent_length,
|
||||
)
|
||||
control_point_2 = end_point + (
|
||||
end_point.y * tangent_length,
|
||||
-end_point.x * tangent_length,
|
||||
)
|
||||
yield start_point, control_point_1, control_point_2, end_point
|
||||
@@ -0,0 +1,278 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# Pure Python implementation of the B-spline basis function.
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, Optional
|
||||
import math
|
||||
import bisect
|
||||
|
||||
# The pure Python implementation can't import from ._ctypes or ezdxf.math!
|
||||
from ._vector import Vec3, NULLVEC
|
||||
from .linalg import binomial_coefficient
|
||||
|
||||
__all__ = ["Basis", "Evaluator"]
|
||||
|
||||
|
||||
class Basis:
|
||||
"""Immutable Basis function class."""
|
||||
|
||||
__slots__ = ("_knots", "_weights", "_order", "_count")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
knots: Iterable[float],
|
||||
order: int,
|
||||
count: int,
|
||||
weights: Optional[Sequence[float]] = None,
|
||||
):
|
||||
self._knots = tuple(knots)
|
||||
self._weights = tuple(weights or [])
|
||||
self._order: int = int(order)
|
||||
self._count: int = int(count)
|
||||
|
||||
# validation checks:
|
||||
len_weights = len(self._weights)
|
||||
if len_weights != 0 and len_weights != self._count:
|
||||
raise ValueError("invalid weight count")
|
||||
if len(self._knots) != self._order + self._count:
|
||||
raise ValueError("invalid knot count")
|
||||
|
||||
@property
|
||||
def max_t(self) -> float:
|
||||
return self._knots[-1]
|
||||
|
||||
@property
|
||||
def order(self) -> int:
|
||||
return self._order
|
||||
|
||||
@property
|
||||
def degree(self) -> int:
|
||||
return self._order - 1
|
||||
|
||||
@property
|
||||
def knots(self) -> tuple[float, ...]:
|
||||
return self._knots
|
||||
|
||||
@property
|
||||
def weights(self) -> tuple[float, ...]:
|
||||
return self._weights
|
||||
|
||||
@property
|
||||
def is_rational(self) -> bool:
|
||||
"""Returns ``True`` if curve is a rational B-spline. (has weights)"""
|
||||
return bool(self._weights)
|
||||
|
||||
def basis_vector(self, t: float) -> list[float]:
|
||||
"""Returns the expanded basis vector."""
|
||||
span = self.find_span(t)
|
||||
p = self._order - 1
|
||||
front = span - p
|
||||
back = self._count - span - 1
|
||||
basis = self.basis_funcs(span, t)
|
||||
return ([0.0] * front) + basis + ([0.0] * back)
|
||||
|
||||
def find_span(self, u: float) -> int:
|
||||
"""Determine the knot span index."""
|
||||
# Linear search is more reliable than binary search of the Algorithm A2.1
|
||||
# from The NURBS Book by Piegl & Tiller.
|
||||
knots = self._knots
|
||||
count = self._count # text book: n+1
|
||||
if u >= knots[count]: # special case
|
||||
return count - 1 # n
|
||||
p = self._order - 1
|
||||
# common clamped spline:
|
||||
if knots[p] == 0.0: # use binary search
|
||||
# This is fast and works most of the time,
|
||||
# but Test 621 : test_weired_closed_spline()
|
||||
# goes into an infinity loop, because of
|
||||
# a weird knot configuration.
|
||||
return bisect.bisect_right(knots, u, p, count) - 1
|
||||
else: # use linear search
|
||||
span = 0
|
||||
while knots[span] <= u and span < count:
|
||||
span += 1
|
||||
return span - 1
|
||||
|
||||
def basis_funcs(self, span: int, u: float) -> list[float]:
|
||||
# Source: The NURBS Book: Algorithm A2.2
|
||||
order = self._order
|
||||
knots = self._knots
|
||||
N = [0.0] * order
|
||||
left = list(N)
|
||||
right = list(N)
|
||||
N[0] = 1.0
|
||||
for j in range(1, order):
|
||||
left[j] = u - knots[max(0, span + 1 - j)]
|
||||
right[j] = knots[span + j] - u
|
||||
saved = 0.0
|
||||
for r in range(j):
|
||||
temp = N[r] / (right[r + 1] + left[j - r])
|
||||
N[r] = saved + right[r + 1] * temp
|
||||
saved = left[j - r] * temp
|
||||
N[j] = saved
|
||||
if self.is_rational:
|
||||
return self.span_weighting(N, span)
|
||||
else:
|
||||
return N
|
||||
|
||||
def span_weighting(self, nbasis: list[float], span: int) -> list[float]:
|
||||
size = len(nbasis)
|
||||
weights = self._weights[span - self._order + 1 : span + 1]
|
||||
if len(weights) != size:
|
||||
return nbasis
|
||||
|
||||
products = [nb * w for nb, w in zip(nbasis, weights)]
|
||||
s = sum(products)
|
||||
return nbasis if s == 0.0 else [p / s for p in products]
|
||||
|
||||
def basis_funcs_derivatives(self, span: int, u: float, n: int = 1):
|
||||
# Source: The NURBS Book: Algorithm A2.3
|
||||
order = self._order
|
||||
p = order - 1
|
||||
n = min(n, p)
|
||||
|
||||
knots = self._knots
|
||||
left = [1.0] * order
|
||||
right = [1.0] * order
|
||||
ndu = [[1.0] * order for _ in range(order)]
|
||||
|
||||
for j in range(1, order):
|
||||
left[j] = u - knots[max(0, span + 1 - j)]
|
||||
right[j] = knots[span + j] - u
|
||||
saved = 0.0
|
||||
for r in range(j):
|
||||
# lower triangle
|
||||
ndu[j][r] = right[r + 1] + left[j - r]
|
||||
temp = ndu[r][j - 1] / ndu[j][r]
|
||||
# upper triangle
|
||||
ndu[r][j] = saved + (right[r + 1] * temp)
|
||||
saved = left[j - r] * temp
|
||||
ndu[j][j] = saved
|
||||
|
||||
# load the basis_vector functions
|
||||
derivatives = [[0.0] * order for _ in range(order)]
|
||||
for j in range(order):
|
||||
derivatives[0][j] = ndu[j][p]
|
||||
|
||||
# loop over function index
|
||||
a = [[1.0] * order, [1.0] * order]
|
||||
for r in range(order):
|
||||
s1 = 0
|
||||
s2 = 1
|
||||
# alternate rows in array a
|
||||
a[0][0] = 1.0
|
||||
|
||||
# loop to compute kth derivative
|
||||
for k in range(1, n + 1):
|
||||
d = 0.0
|
||||
rk = r - k
|
||||
pk = p - k
|
||||
if r >= k:
|
||||
a[s2][0] = a[s1][0] / ndu[pk + 1][rk]
|
||||
d = a[s2][0] * ndu[rk][pk]
|
||||
if rk >= -1:
|
||||
j1 = 1
|
||||
else:
|
||||
j1 = -rk
|
||||
if (r - 1) <= pk:
|
||||
j2 = k - 1
|
||||
else:
|
||||
j2 = p - r
|
||||
for j in range(j1, j2 + 1):
|
||||
a[s2][j] = (a[s1][j] - a[s1][j - 1]) / ndu[pk + 1][rk + j]
|
||||
d += a[s2][j] * ndu[rk + j][pk]
|
||||
if r <= pk:
|
||||
a[s2][k] = -a[s1][k - 1] / ndu[pk + 1][r]
|
||||
d += a[s2][k] * ndu[r][pk]
|
||||
derivatives[k][r] = d
|
||||
|
||||
# Switch rows
|
||||
s1, s2 = s2, s1
|
||||
|
||||
# Multiply through by the correct factors
|
||||
r = float(p) # type: ignore
|
||||
for k in range(1, n + 1):
|
||||
for j in range(order):
|
||||
derivatives[k][j] *= r
|
||||
r *= p - k
|
||||
return derivatives[: n + 1]
|
||||
|
||||
|
||||
class Evaluator:
|
||||
"""B-spline curve point and curve derivative evaluator."""
|
||||
|
||||
__slots__ = ["_basis", "_control_points"]
|
||||
|
||||
def __init__(self, basis: Basis, control_points: Sequence[Vec3]):
|
||||
self._basis = basis
|
||||
self._control_points = control_points
|
||||
|
||||
def point(self, u: float) -> Vec3:
|
||||
# Source: The NURBS Book: Algorithm A3.1
|
||||
basis = self._basis
|
||||
control_points = self._control_points
|
||||
if math.isclose(u, basis.max_t):
|
||||
u = basis.max_t
|
||||
|
||||
p = basis.degree
|
||||
span = basis.find_span(u)
|
||||
N = basis.basis_funcs(span, u)
|
||||
return Vec3.sum(
|
||||
N[i] * control_points[span - p + i] for i in range(p + 1)
|
||||
)
|
||||
|
||||
def points(self, t: Iterable[float]) -> Iterable[Vec3]:
|
||||
for u in t:
|
||||
yield self.point(u)
|
||||
|
||||
def derivative(self, u: float, n: int = 1) -> list[Vec3]:
|
||||
"""Return point and derivatives up to n <= degree for parameter u."""
|
||||
# Source: The NURBS Book: Algorithm A3.2
|
||||
basis = self._basis
|
||||
control_points = self._control_points
|
||||
if math.isclose(u, basis.max_t):
|
||||
u = basis.max_t
|
||||
|
||||
p = basis.degree
|
||||
span = basis.find_span(u)
|
||||
basis_funcs_ders = basis.basis_funcs_derivatives(span, u, n)
|
||||
if basis.is_rational:
|
||||
# Homogeneous point representation required:
|
||||
# (x*w, y*w, z*w, w)
|
||||
CKw: list[Vec3] = []
|
||||
wders: list[float] = []
|
||||
weights = basis.weights
|
||||
for k in range(n + 1):
|
||||
v = NULLVEC
|
||||
wder = 0.0
|
||||
for j in range(p + 1):
|
||||
index = span - p + j
|
||||
bas_func_weight = basis_funcs_ders[k][j] * weights[index]
|
||||
# control_point * weight * bas_func_der = (x*w, y*w, z*w) * bas_func_der
|
||||
v += control_points[index] * bas_func_weight
|
||||
wder += bas_func_weight
|
||||
CKw.append(v)
|
||||
wders.append(wder)
|
||||
|
||||
# Source: The NURBS Book: Algorithm A4.2
|
||||
CK: list[Vec3] = []
|
||||
for k in range(n + 1):
|
||||
v = CKw[k]
|
||||
for i in range(1, k + 1):
|
||||
v -= binomial_coefficient(k, i) * wders[i] * CK[k - i]
|
||||
CK.append(v / wders[0])
|
||||
else:
|
||||
CK = [
|
||||
Vec3.sum(
|
||||
basis_funcs_ders[k][j] * control_points[span - p + j]
|
||||
for j in range(p + 1)
|
||||
)
|
||||
for k in range(n + 1)
|
||||
]
|
||||
return CK
|
||||
|
||||
def derivatives(
|
||||
self, t: Iterable[float], n: int = 1
|
||||
) -> Iterable[list[Vec3]]:
|
||||
for u in t:
|
||||
yield self.derivative(u, n)
|
||||
@@ -0,0 +1,362 @@
|
||||
# Copyright (c) 2011-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# These are the pure Python implementations of the Cython accelerated
|
||||
# construction tools: ezdxf/acc/construct.pyx
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, Optional, TYPE_CHECKING
|
||||
import math
|
||||
|
||||
# The pure Python implementation can't import from ._ctypes or ezdxf.math!
|
||||
from ._vector import Vec2, Vec3
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.math import UVec
|
||||
|
||||
TOLERANCE = 1e-10
|
||||
RAD_ABS_TOL = 1e-15
|
||||
DEG_ABS_TOL = 1e-13
|
||||
|
||||
|
||||
def has_clockwise_orientation(vertices: Iterable[UVec]) -> bool:
|
||||
"""Returns ``True`` if the given 2D `vertices` have clockwise orientation.
|
||||
Ignores the z-axis of all vertices.
|
||||
|
||||
Args:
|
||||
vertices: iterable of :class:`Vec2` compatible objects
|
||||
|
||||
Raises:
|
||||
ValueError: less than 3 vertices
|
||||
|
||||
"""
|
||||
vertices = Vec2.list(vertices)
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("At least 3 vertices required.")
|
||||
|
||||
# close polygon:
|
||||
if not vertices[0].isclose(vertices[-1]):
|
||||
vertices.append(vertices[0])
|
||||
|
||||
return (
|
||||
sum(
|
||||
(p2.x - p1.x) * (p2.y + p1.y)
|
||||
for p1, p2 in zip(vertices, vertices[1:])
|
||||
)
|
||||
> 0.0
|
||||
)
|
||||
|
||||
|
||||
def intersection_line_line_2d(
|
||||
line1: Sequence[Vec2],
|
||||
line2: Sequence[Vec2],
|
||||
virtual=True,
|
||||
abs_tol=TOLERANCE,
|
||||
) -> Optional[Vec2]:
|
||||
"""
|
||||
Compute the intersection of two lines in the xy-plane.
|
||||
|
||||
Args:
|
||||
line1: start- and end point of first line to test
|
||||
e.g. ((x1, y1), (x2, y2)).
|
||||
line2: start- and end point of second line to test
|
||||
e.g. ((x3, y3), (x4, y4)).
|
||||
virtual: ``True`` returns any intersection point, ``False`` returns
|
||||
only real intersection points.
|
||||
abs_tol: tolerance for intersection test.
|
||||
|
||||
Returns:
|
||||
``None`` if there is no intersection point (parallel lines) or
|
||||
intersection point as :class:`Vec2`
|
||||
|
||||
"""
|
||||
# Algorithm based on: http://paulbourke.net/geometry/pointlineplane/
|
||||
# chapter: Intersection point of two line segments in 2 dimensions
|
||||
s1, s2 = line1 # the subject line
|
||||
c1, c2 = line2 # the clipping line
|
||||
s1x = s1.x
|
||||
s1y = s1.y
|
||||
s2x = s2.x
|
||||
s2y = s2.y
|
||||
c1x = c1.x
|
||||
c1y = c1.y
|
||||
c2x = c2.x
|
||||
c2y = c2.y
|
||||
|
||||
den = (c2y - c1y) * (s2x - s1x) - (c2x - c1x) * (s2y - s1y)
|
||||
if math.fabs(den) <= abs_tol:
|
||||
return None
|
||||
|
||||
us = ((c2x - c1x) * (s1y - c1y) - (c2y - c1y) * (s1x - c1x)) / den
|
||||
intersection_point = Vec2(s1x + us * (s2x - s1x), s1y + us * (s2y - s1y))
|
||||
if virtual:
|
||||
return intersection_point
|
||||
|
||||
# 0 = intersection point is the start point of the line
|
||||
# 1 = intersection point is the end point of the line
|
||||
# otherwise: linear interpolation
|
||||
lwr = 0.0 # tolerances required?
|
||||
upr = 1.0 # tolerances required?
|
||||
if lwr <= us <= upr: # intersection point is on the subject line
|
||||
uc = ((s2x - s1x) * (s1y - c1y) - (s2y - s1y) * (s1x - c1x)) / den
|
||||
if lwr <= uc <= upr: # intersection point is on the clipping line
|
||||
return intersection_point
|
||||
return None
|
||||
|
||||
|
||||
def _determinant(v1, v2, v3) -> float:
|
||||
"""Returns determinant."""
|
||||
e11, e12, e13 = v1
|
||||
e21, e22, e23 = v2
|
||||
e31, e32, e33 = v3
|
||||
|
||||
return (
|
||||
e11 * e22 * e33
|
||||
+ e12 * e23 * e31
|
||||
+ e13 * e21 * e32
|
||||
- e13 * e22 * e31
|
||||
- e11 * e23 * e32
|
||||
- e12 * e21 * e33
|
||||
)
|
||||
|
||||
|
||||
def intersection_ray_ray_3d(
|
||||
ray1: Sequence[Vec3], ray2: Sequence[Vec3], abs_tol=TOLERANCE
|
||||
) -> Sequence[Vec3]:
|
||||
"""
|
||||
Calculate intersection of two 3D rays, returns a 0-tuple for parallel rays,
|
||||
a 1-tuple for intersecting rays and a 2-tuple for not intersecting and not
|
||||
parallel rays with points of the closest approach on each ray.
|
||||
|
||||
Args:
|
||||
ray1: first ray as tuple of two points as :class:`Vec3` objects
|
||||
ray2: second ray as tuple of two points as :class:`Vec3` objects
|
||||
abs_tol: absolute tolerance for comparisons
|
||||
|
||||
"""
|
||||
# source: http://www.realtimerendering.com/intersections.html#I304
|
||||
o1, p1 = ray1
|
||||
d1 = (p1 - o1).normalize()
|
||||
o2, p2 = ray2
|
||||
d2 = (p2 - o2).normalize()
|
||||
d1xd2 = d1.cross(d2)
|
||||
denominator = d1xd2.magnitude_square
|
||||
if denominator <= abs_tol:
|
||||
# ray1 is parallel to ray2
|
||||
return tuple()
|
||||
else:
|
||||
o2_o1 = o2 - o1
|
||||
det1 = _determinant(o2_o1, d2, d1xd2)
|
||||
det2 = _determinant(o2_o1, d1, d1xd2)
|
||||
p1 = o1 + d1 * (det1 / denominator)
|
||||
p2 = o2 + d2 * (det2 / denominator)
|
||||
if p1.isclose(p2, abs_tol=abs_tol):
|
||||
# ray1 and ray2 have an intersection point
|
||||
return (p1,)
|
||||
else:
|
||||
# ray1 and ray2 do not have an intersection point,
|
||||
# p1 and p2 are the points of closest approach on each ray
|
||||
return p1, p2
|
||||
|
||||
|
||||
def arc_angle_span_deg(start: float, end: float) -> float:
|
||||
"""Returns the counter-clockwise angle span from `start` to `end` in degrees.
|
||||
|
||||
Returns the angle span in the range of [0, 360], 360 is a full circle.
|
||||
Full circle handling is a special case, because normalization of angles
|
||||
which describe a full circle would return 0 if treated as regular angles.
|
||||
e.g. (0, 360) → 360, (0, -360) → 360, (180, -180) → 360.
|
||||
Input angles with the same value always return 0 by definition: (0, 0) → 0,
|
||||
(-180, -180) → 0, (360, 360) → 0.
|
||||
|
||||
"""
|
||||
# Input values are equal, returns 0 by definition:
|
||||
if math.isclose(start, end, abs_tol=DEG_ABS_TOL):
|
||||
return 0.0
|
||||
|
||||
# Normalized start- and end angles are equal, but input values are
|
||||
# different, returns 360 by definition:
|
||||
start %= 360.0
|
||||
if math.isclose(start, end % 360.0, abs_tol=DEG_ABS_TOL):
|
||||
return 360.0
|
||||
|
||||
# Special treatment for end angle == 360 deg:
|
||||
if not math.isclose(end, 360.0, abs_tol=DEG_ABS_TOL):
|
||||
end %= 360.0
|
||||
|
||||
if end < start:
|
||||
end += 360.0
|
||||
return end - start
|
||||
|
||||
|
||||
def arc_angle_span_rad(start: float, end: float) -> float:
|
||||
"""Returns the counter-clockwise angle span from `start` to `end` in radians.
|
||||
|
||||
Returns the angle span in the range of [0, 2π], 2π is a full circle.
|
||||
Full circle handling is a special case, because normalization of angles
|
||||
which describe a full circle would return 0 if treated as regular angles.
|
||||
e.g. (0, 2π) → 2π, (0, -2π) → 2π, (π, -π) → 2π.
|
||||
Input angles with the same value always return 0 by definition: (0, 0) → 0,
|
||||
(-π, -π) → 0, (2π, 2π) → 0.
|
||||
|
||||
"""
|
||||
tau = math.tau
|
||||
# Input values are equal, returns 0 by definition:
|
||||
if math.isclose(start, end, abs_tol=RAD_ABS_TOL):
|
||||
return 0.0
|
||||
|
||||
# Normalized start- and end angles are equal, but input values are
|
||||
# different, returns 360 by definition:
|
||||
start %= tau
|
||||
if math.isclose(start, end % tau, abs_tol=RAD_ABS_TOL):
|
||||
return tau
|
||||
|
||||
# Special treatment for end angle == 2π:
|
||||
if not math.isclose(end, tau, abs_tol=RAD_ABS_TOL):
|
||||
end %= tau
|
||||
|
||||
if end < start:
|
||||
end += tau
|
||||
return end - start
|
||||
|
||||
|
||||
def is_point_in_polygon_2d(
|
||||
point: Vec2, polygon: list[Vec2], abs_tol=TOLERANCE
|
||||
) -> int:
|
||||
"""
|
||||
Test if `point` is inside `polygon`. Returns +1 for inside, 0 for on the
|
||||
boundary and -1 for outside.
|
||||
|
||||
Supports convex and concave polygons with clockwise or counter-clockwise oriented
|
||||
polygon vertices. Does not raise an exception for degenerated polygons.
|
||||
|
||||
|
||||
Args:
|
||||
point: 2D point to test as :class:`Vec2`
|
||||
polygon: list of 2D points as :class:`Vec2`
|
||||
abs_tol: tolerance for distance check
|
||||
|
||||
Returns:
|
||||
+1 for inside, 0 for on the boundary, -1 for outside
|
||||
|
||||
"""
|
||||
# Source: http://www.faqs.org/faqs/graphics/algorithms-faq/
|
||||
# Subject 2.03: How do I find if a point lies within a polygon?
|
||||
# polygon: the Cython implementation needs a list as input to be fast!
|
||||
assert isinstance(polygon, list)
|
||||
if len(polygon) < 3: # empty polygon
|
||||
return -1
|
||||
|
||||
if polygon[0].isclose(polygon[-1]): # open polygon is required
|
||||
polygon = polygon[:-1]
|
||||
if len(polygon) < 3:
|
||||
return -1
|
||||
x = point.x
|
||||
y = point.y
|
||||
inside = False
|
||||
x1, y1 = polygon[-1]
|
||||
for x2, y2 in polygon:
|
||||
# is point on polygon boundary line:
|
||||
# is point in x-range of line
|
||||
a, b = (x2, x1) if x2 < x1 else (x1, x2)
|
||||
if a <= x <= b:
|
||||
# is point in y-range of line
|
||||
c, d = (y2, y1) if y2 < y1 else (y1, y2)
|
||||
if (c <= y <= d) and abs(
|
||||
(y2 - y1) * x - (x2 - x1) * y + (x2 * y1 - y2 * x1)
|
||||
) <= abs_tol:
|
||||
return 0 # on boundary line
|
||||
if ((y1 <= y < y2) or (y2 <= y < y1)) and (
|
||||
x < (x2 - x1) * (y - y1) / (y2 - y1) + x1
|
||||
):
|
||||
inside = not inside
|
||||
x1 = x2
|
||||
y1 = y2
|
||||
if inside:
|
||||
return 1 # inside polygon
|
||||
else:
|
||||
return -1 # outside polygon
|
||||
|
||||
|
||||
# Values stored in GeoData RSS tag are not precise enough to match
|
||||
# control calculation at epsg.io:
|
||||
# Semi Major Axis: 6.37814e+06
|
||||
# Semi Minor Axis: 6.35675e+06
|
||||
WGS84_SEMI_MAJOR_AXIS = 6378137
|
||||
WGS84_SEMI_MINOR_AXIS = 6356752.3142
|
||||
WGS84_ELLIPSOID_ECCENTRIC = 0.08181919092890624
|
||||
# WGS84_ELLIPSOID_ECCENTRIC = math.sqrt(
|
||||
# 1.0 - WGS84_SEMI_MINOR_AXIS**2 / WGS84_SEMI_MAJOR_AXIS**2
|
||||
# )
|
||||
CONST_E2 = 1.3591409142295225 # math.e / 2.0
|
||||
CONST_PI_2 = 1.5707963267948966 # math.pi / 2.0
|
||||
CONST_PI_4 = 0.7853981633974483 # math.pi / 4.0
|
||||
|
||||
|
||||
def gps_to_world_mercator(longitude: float, latitude: float) -> tuple[float, float]:
|
||||
"""Transform GPS (long/lat) to World Mercator.
|
||||
|
||||
Transform WGS84 `EPSG:4326 <https://epsg.io/4326>`_ location given as
|
||||
latitude and longitude in decimal degrees as used by GPS into World Mercator
|
||||
cartesian 2D coordinates in meters `EPSG:3395 <https://epsg.io/3395>`_.
|
||||
|
||||
Args:
|
||||
longitude: represents the longitude value (East-West) in decimal degrees
|
||||
latitude: represents the latitude value (North-South) in decimal degrees.
|
||||
|
||||
.. versionadded:: 1.3.0
|
||||
|
||||
"""
|
||||
# From: https://epsg.io/4326
|
||||
# EPSG:4326 WGS84 - World Geodetic System 1984, used in GPS
|
||||
# To: https://epsg.io/3395
|
||||
# EPSG:3395 - World Mercator
|
||||
# Source: https://gis.stackexchange.com/questions/259121/transformation-functions-for-epsg3395-projection-vs-epsg3857
|
||||
longitude = math.radians(longitude) # east
|
||||
latitude = math.radians(latitude) # north
|
||||
a = WGS84_SEMI_MAJOR_AXIS
|
||||
e = WGS84_ELLIPSOID_ECCENTRIC
|
||||
e_sin_lat = math.sin(latitude) * e
|
||||
c = math.pow((1.0 - e_sin_lat) / (1.0 + e_sin_lat), e / 2.0) # 7-7 p.44
|
||||
y = a * math.log(math.tan(CONST_PI_4 + latitude / 2.0) * c) # 7-7 p.44
|
||||
x = a * longitude
|
||||
return x, y
|
||||
|
||||
|
||||
def world_mercator_to_gps(x: float, y: float, tol: float = 1e-6) -> tuple[float, float]:
|
||||
"""Transform World Mercator to GPS.
|
||||
|
||||
Transform WGS84 World Mercator `EPSG:3395 <https://epsg.io/3395>`_
|
||||
location given as cartesian 2D coordinates x, y in meters into WGS84 decimal
|
||||
degrees as longitude and latitude `EPSG:4326 <https://epsg.io/4326>`_ as
|
||||
used by GPS.
|
||||
|
||||
Args:
|
||||
x: coordinate WGS84 World Mercator
|
||||
y: coordinate WGS84 World Mercator
|
||||
tol: accuracy for latitude calculation
|
||||
|
||||
.. versionadded:: 1.3.0
|
||||
|
||||
"""
|
||||
# From: https://epsg.io/3395
|
||||
# EPSG:3395 - World Mercator
|
||||
# To: https://epsg.io/4326
|
||||
# EPSG:4326 WGS84 - World Geodetic System 1984, used in GPS
|
||||
# Source: Map Projections - A Working Manual
|
||||
# https://pubs.usgs.gov/pp/1395/report.pdf
|
||||
a = WGS84_SEMI_MAJOR_AXIS
|
||||
e = WGS84_ELLIPSOID_ECCENTRIC
|
||||
e2 = e / 2.0
|
||||
pi2 = CONST_PI_2
|
||||
t = math.e ** (-y / a) # 7-10 p.44
|
||||
latitude_ = pi2 - 2.0 * math.atan(t) # 7-11 p.45
|
||||
while True:
|
||||
e_sin_lat = math.sin(latitude_) * e
|
||||
latitude = pi2 - 2.0 * math.atan(
|
||||
t * ((1.0 - e_sin_lat) / (1.0 + e_sin_lat)) ** e2
|
||||
) # 7-9 p.44
|
||||
if abs(latitude - latitude_) < tol:
|
||||
break
|
||||
latitude_ = latitude
|
||||
|
||||
longitude = x / a # 7-12 p.45
|
||||
return math.degrees(longitude), math.degrees(latitude)
|
||||
@@ -0,0 +1,101 @@
|
||||
# Copyright (c) 2020-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from typing import Union, Sequence
|
||||
from typing_extensions import TypeAlias
|
||||
# noinspection PyUnresolvedReferences
|
||||
from ezdxf.acc import USE_C_EXT
|
||||
|
||||
__all__ = [
|
||||
"Vec3",
|
||||
"Vec2",
|
||||
"AnyVec",
|
||||
"UVec",
|
||||
"X_AXIS",
|
||||
"Y_AXIS",
|
||||
"Z_AXIS",
|
||||
"NULLVEC",
|
||||
"distance",
|
||||
"lerp",
|
||||
"Matrix44",
|
||||
"Bezier4P",
|
||||
"Bezier3P",
|
||||
"Basis",
|
||||
"Evaluator",
|
||||
"cubic_bezier_arc_parameters",
|
||||
"cubic_bezier_from_arc",
|
||||
"cubic_bezier_from_ellipse",
|
||||
"has_clockwise_orientation",
|
||||
"intersection_line_line_2d",
|
||||
"intersection_ray_ray_3d",
|
||||
"arc_angle_span_deg",
|
||||
"arc_angle_span_rad",
|
||||
"is_point_in_polygon_2d",
|
||||
"world_mercator_to_gps",
|
||||
"gps_to_world_mercator",
|
||||
]
|
||||
# Import of Python or Cython implementations:
|
||||
if USE_C_EXT:
|
||||
from ezdxf.acc.vector import (
|
||||
Vec3,
|
||||
Vec2,
|
||||
X_AXIS,
|
||||
Y_AXIS,
|
||||
Z_AXIS,
|
||||
NULLVEC,
|
||||
distance,
|
||||
lerp,
|
||||
)
|
||||
from ezdxf.acc.matrix44 import Matrix44
|
||||
from ezdxf.acc.bezier4p import (
|
||||
Bezier4P,
|
||||
cubic_bezier_arc_parameters,
|
||||
cubic_bezier_from_arc,
|
||||
cubic_bezier_from_ellipse,
|
||||
)
|
||||
from ezdxf.acc.bezier3p import Bezier3P
|
||||
from ezdxf.acc.bspline import Basis, Evaluator
|
||||
from ezdxf.acc.construct import (
|
||||
has_clockwise_orientation,
|
||||
intersection_line_line_2d,
|
||||
intersection_ray_ray_3d,
|
||||
arc_angle_span_deg,
|
||||
arc_angle_span_rad,
|
||||
is_point_in_polygon_2d,
|
||||
world_mercator_to_gps,
|
||||
gps_to_world_mercator,
|
||||
|
||||
)
|
||||
else:
|
||||
from ._vector import (
|
||||
Vec3,
|
||||
Vec2,
|
||||
X_AXIS,
|
||||
Y_AXIS,
|
||||
Z_AXIS,
|
||||
NULLVEC,
|
||||
distance,
|
||||
lerp,
|
||||
)
|
||||
from ._matrix44 import Matrix44
|
||||
from ._bezier4p import (
|
||||
Bezier4P,
|
||||
cubic_bezier_arc_parameters,
|
||||
cubic_bezier_from_arc,
|
||||
cubic_bezier_from_ellipse,
|
||||
)
|
||||
from ._bezier3p import Bezier3P
|
||||
from ._bspline import Basis, Evaluator
|
||||
from ._construct import (
|
||||
has_clockwise_orientation,
|
||||
intersection_line_line_2d,
|
||||
intersection_ray_ray_3d,
|
||||
arc_angle_span_deg,
|
||||
arc_angle_span_rad,
|
||||
is_point_in_polygon_2d,
|
||||
world_mercator_to_gps,
|
||||
gps_to_world_mercator,
|
||||
)
|
||||
|
||||
# Early required type aliases
|
||||
AnyVec: TypeAlias = Union[Vec2, Vec3]
|
||||
UVec: TypeAlias = Union[Sequence[float], Vec2, Vec3]
|
||||
@@ -0,0 +1,831 @@
|
||||
# Source: https://github.com/mapbox/earcut
|
||||
# License: ISC License (MIT compatible)
|
||||
#
|
||||
# Copyright (c) 2016, Mapbox
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
# with or without fee is hereby granted, provided that the above copyright notice
|
||||
# and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
# THIS SOFTWARE.
|
||||
#
|
||||
# The Algorithm
|
||||
# -------------
|
||||
# The library implements a modified ear slicing algorithm, optimized by z-order
|
||||
# curve hashing and extended to handle holes, twisted polygons, degeneracies and
|
||||
# self-intersections in a way that doesn't guarantee correctness of triangulation,
|
||||
# but attempts to always produce acceptable results for practical data.
|
||||
#
|
||||
# Translation to Python:
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
#
|
||||
# Notes
|
||||
# -----
|
||||
# Exterior path (outer path) vertices are stored in counter-clockwise order
|
||||
# Hole vertices are stored in clockwise order
|
||||
# Vertex order will be maintained by the algorithm automatically.
|
||||
# Boundary behavior for holes:
|
||||
# - holes outside the exterior path are ignored
|
||||
# - invalid result for holes partially extending beyond the exterior path
|
||||
# - invalid result for overlapping holes
|
||||
# - invalid result for holes in holes
|
||||
# Very stable in all circumstances - DOES NOT CRASH!
|
||||
#
|
||||
# Steiner Point
|
||||
# -------------
|
||||
# https://en.wikipedia.org/wiki/Steiner_point_(computational_geometry)
|
||||
# A Steiner point is a point that is not part of the input to a geometric
|
||||
# optimization problem but is added during the solution of the problem, to
|
||||
# create a better solution than would be possible from the original points
|
||||
# alone.
|
||||
# A Steiner point is defined as a hole with a single point!
|
||||
#
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Optional, Protocol, TypeVar
|
||||
|
||||
import math
|
||||
|
||||
|
||||
class Point(Protocol):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, i: int, point: Point) -> None:
|
||||
self.i: int = i
|
||||
|
||||
# store source point for output
|
||||
self.point = point
|
||||
|
||||
# vertex coordinates
|
||||
self.x: float = point.x
|
||||
self.y: float = point.y
|
||||
|
||||
# previous and next vertex nodes in a polygon ring
|
||||
self.prev: Node = None # type: ignore
|
||||
self.next: Node = None # type: ignore
|
||||
|
||||
# z-order curve value
|
||||
self.z: int = 0
|
||||
|
||||
# previous and next nodes in z-order
|
||||
self.prev_z: Node = None # type: ignore
|
||||
self.next_z: Node = None # type: ignore
|
||||
|
||||
# indicates whether this is a steiner point
|
||||
self.steiner: bool = False
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.x == other.x and self.y == other.y
|
||||
|
||||
|
||||
T = TypeVar("T", bound=Point)
|
||||
|
||||
|
||||
def earcut(exterior: list[T], holes: list[list[T]]) -> list[Sequence[T]]:
|
||||
"""Implements a modified ear slicing algorithm, optimized by z-order
|
||||
curve hashing and extended to handle holes, twisted polygons, degeneracies
|
||||
and self-intersections in a way that doesn't guarantee correctness of
|
||||
triangulation, but attempts to always produce acceptable results for
|
||||
practical data.
|
||||
|
||||
Source: https://github.com/mapbox/earcut
|
||||
|
||||
Args:
|
||||
exterior: outer path as list of points as objects which provide a
|
||||
`x`- and a `y`-attribute
|
||||
holes: list of holes, each hole is list of points, a hole with
|
||||
a single points is a Steiner point
|
||||
|
||||
Returns:
|
||||
Returns a list of triangles, each triangle is a tuple of three points,
|
||||
the output points are the same objects as the input points.
|
||||
|
||||
"""
|
||||
# exterior points in counter-clockwise order
|
||||
outer_node: Node = linked_list(exterior, 0, ccw=True)
|
||||
triangles: list[Sequence[T]] = []
|
||||
|
||||
if outer_node is None or outer_node.next is outer_node.prev:
|
||||
return triangles
|
||||
|
||||
if len(holes) > 0:
|
||||
outer_node = eliminate_holes(holes, len(exterior), outer_node)
|
||||
|
||||
min_x: float = 0.0
|
||||
min_y: float = 0.0
|
||||
inv_size: float = 0.0
|
||||
|
||||
# if the shape is not too simple, we'll use z-order curve hash later
|
||||
# calculate polygon bbox
|
||||
if len(exterior) > 80:
|
||||
min_x = max_x = exterior[0].x
|
||||
min_y = max_y = exterior[0].y
|
||||
for point in exterior:
|
||||
x = point.x
|
||||
y = point.y
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
|
||||
# min_x, min_y and inv_size are later used to transform coords into
|
||||
# integers for z-order calculation
|
||||
inv_size = max(max_x - min_x, max_y - min_y)
|
||||
inv_size = 32767 / inv_size if inv_size != 0 else 0
|
||||
|
||||
earcut_linked(outer_node, triangles, min_x, min_y, inv_size, 0) # type: ignore
|
||||
return triangles
|
||||
|
||||
|
||||
def linked_list(points: Sequence[Point], start: int, ccw: bool) -> Node:
|
||||
"""Create a circular doubly linked list from polygon points in the specified
|
||||
winding order
|
||||
"""
|
||||
last: Node = None # type: ignore
|
||||
if ccw is (signed_area(points) < 0):
|
||||
for point in points:
|
||||
last = insert_node(start, point, last)
|
||||
start += 1
|
||||
else:
|
||||
end = start + len(points)
|
||||
for point in reversed(points):
|
||||
last = insert_node(end, point, last)
|
||||
end -= 1
|
||||
|
||||
# open polygon: where the 1st vertex is not coincident with the last vertex
|
||||
if last and last == last.next: # true equals
|
||||
remove_node(last)
|
||||
last = last.next
|
||||
return last
|
||||
|
||||
|
||||
def signed_area(points: Sequence[Point]) -> float:
|
||||
s: float = 0.0
|
||||
if not len(points):
|
||||
return s
|
||||
prev = points[-1]
|
||||
for point in points:
|
||||
s += (point.x - prev.x) * (point.y + prev.y)
|
||||
prev = point
|
||||
# s < 0 is counter-clockwise
|
||||
# s > 0 is clockwise
|
||||
return s
|
||||
|
||||
|
||||
def area(p: Node, q: Node, r: Node) -> float:
|
||||
"""Returns signed area of a triangle"""
|
||||
return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
|
||||
|
||||
|
||||
def is_valid_diagonal(a: Node, b: Node):
|
||||
"""Check if a diagonal between two polygon nodes is valid (lies in polygon
|
||||
interior)
|
||||
"""
|
||||
return (
|
||||
a.next.i != b.i
|
||||
and a.prev.i != b.i
|
||||
and not intersects_polygon(a, b) # doesn't intersect other edges
|
||||
and (
|
||||
locally_inside(a, b)
|
||||
and locally_inside(b, a)
|
||||
and middle_inside(a, b)
|
||||
and (
|
||||
area(a.prev, a, b.prev) or area(a, b.prev, b)
|
||||
) # does not create opposite-facing sectors
|
||||
or a == b # true equals
|
||||
and area(a.prev, a, a.next) > 0
|
||||
and area(b.prev, b, b.next) > 0
|
||||
) # special zero-length case
|
||||
)
|
||||
|
||||
|
||||
def intersects_polygon(a: Node, b: Node) -> bool:
|
||||
"""Check if a polygon diagonal intersects any polygon segments"""
|
||||
p = a
|
||||
while True:
|
||||
if (
|
||||
p.i != a.i
|
||||
and p.next.i != a.i
|
||||
and p.i != b.i
|
||||
and p.next.i != b.i
|
||||
and intersects(p, p.next, a, b)
|
||||
):
|
||||
return True
|
||||
p = p.next
|
||||
if p is a:
|
||||
break
|
||||
return False
|
||||
|
||||
|
||||
def sign(num: float) -> int:
|
||||
if num < 0.0:
|
||||
return -1
|
||||
if num > 0.0:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def on_segment(p: Node, q: Node, r: Node) -> bool:
|
||||
return max(p.x, r.x) >= q.x >= min(p.x, r.x) and max(p.y, r.y) >= q.y >= min(
|
||||
p.y, r.y
|
||||
)
|
||||
|
||||
|
||||
def intersects(p1: Node, q1: Node, p2: Node, q2: Node) -> bool:
|
||||
"""check if two segments intersect"""
|
||||
o1 = sign(area(p1, q1, p2))
|
||||
o2 = sign(area(p1, q1, q2))
|
||||
o3 = sign(area(p2, q2, p1))
|
||||
o4 = sign(area(p2, q2, q1))
|
||||
|
||||
if o1 != o2 and o3 != o4:
|
||||
return True # general case
|
||||
|
||||
if o1 == 0 and on_segment(p1, p2, q1):
|
||||
return True # p1, q1 and p2 are collinear and p2 lies on p1q1
|
||||
if o2 == 0 and on_segment(p1, q2, q1):
|
||||
return True # p1, q1 and q2 are collinear and q2 lies on p1q1
|
||||
if o3 == 0 and on_segment(p2, p1, q2):
|
||||
return True # p2, q2 and p1 are collinear and p1 lies on p2q2
|
||||
if o4 == 0 and on_segment(p2, q1, q2):
|
||||
return True # p2, q2 and q1 are collinear and q1 lies on p2q2
|
||||
return False
|
||||
|
||||
|
||||
def insert_node(i: int, point: Point, last: Node) -> Node:
|
||||
"""create a node and optionally link it with previous one (in a circular
|
||||
doubly linked list)
|
||||
"""
|
||||
p = Node(i, point)
|
||||
|
||||
if last is None:
|
||||
p.prev = p
|
||||
p.next = p
|
||||
else:
|
||||
p.next = last.next
|
||||
p.prev = last
|
||||
last.next.prev = p
|
||||
last.next = p
|
||||
return p
|
||||
|
||||
|
||||
def remove_node(p: Node) -> None:
|
||||
p.next.prev = p.prev
|
||||
p.prev.next = p.next
|
||||
|
||||
if p.prev_z:
|
||||
p.prev_z.next_z = p.next_z
|
||||
if p.next_z:
|
||||
p.next_z.prev_z = p.prev_z
|
||||
|
||||
|
||||
def eliminate_holes(
|
||||
holes: Sequence[Sequence[Point]], start: int, outer_node: Node
|
||||
) -> Node:
|
||||
"""link every hole into the outer loop, producing a single-ring polygon
|
||||
without holes
|
||||
"""
|
||||
queue: list[Node] = []
|
||||
for hole in holes:
|
||||
if len(hole) < 1: # skip empty holes
|
||||
continue
|
||||
# hole vertices in clockwise order
|
||||
_list = linked_list(hole, start, ccw=False)
|
||||
if _list is _list.next:
|
||||
_list.steiner = True
|
||||
start += len(hole)
|
||||
queue.append(get_leftmost(_list))
|
||||
queue.sort(key=lambda node: (node.x, node.y))
|
||||
|
||||
# process holes from left to right
|
||||
for hole_ in queue:
|
||||
outer_node = eliminate_hole(hole_, outer_node)
|
||||
return outer_node
|
||||
|
||||
|
||||
def eliminate_hole(hole: Node, outer_node: Node) -> Node:
|
||||
"""Find a bridge between vertices that connects hole with an outer ring and
|
||||
link it
|
||||
"""
|
||||
bridge = find_hole_bridge(hole, outer_node)
|
||||
if bridge is None:
|
||||
return outer_node
|
||||
|
||||
bridge_reverse = split_polygon(bridge, hole)
|
||||
|
||||
# filter collinear points around the cuts
|
||||
filter_points(bridge_reverse, bridge_reverse.next)
|
||||
return filter_points(bridge, bridge.next)
|
||||
|
||||
|
||||
def filter_points(start: Node, end: Optional[Node] = None) -> Node:
|
||||
"""eliminate colinear or duplicate points"""
|
||||
if start is None:
|
||||
return start
|
||||
if end is None:
|
||||
end = start
|
||||
|
||||
p = start
|
||||
|
||||
while True:
|
||||
again = False
|
||||
if not p.steiner and (
|
||||
p == p.next or area(p.prev, p, p.next) == 0 # true equals
|
||||
):
|
||||
remove_node(p)
|
||||
p = end = p.prev
|
||||
if p is p.next:
|
||||
break
|
||||
again = True
|
||||
else:
|
||||
p = p.next
|
||||
if not (again or p is not end):
|
||||
break
|
||||
return end
|
||||
|
||||
|
||||
# main ear slicing loop which triangulates a polygon (given as a linked list)
|
||||
def earcut_linked(
|
||||
ear: Node,
|
||||
triangles: list[Sequence[Point]],
|
||||
min_x: float,
|
||||
min_y: float,
|
||||
inv_size: float,
|
||||
pass_: int,
|
||||
) -> None:
|
||||
if ear is None:
|
||||
return
|
||||
|
||||
# interlink polygon nodes in z-order
|
||||
if not pass_ and inv_size:
|
||||
index_curve(ear, min_x, min_y, inv_size)
|
||||
|
||||
stop = ear
|
||||
|
||||
# iterate through ears, slicing them one by one
|
||||
while ear.prev is not ear.next:
|
||||
prev = ear.prev
|
||||
next = ear.next
|
||||
|
||||
_is_ear = (
|
||||
is_ear_hashed(ear, min_x, min_y, inv_size) if inv_size else is_ear(ear)
|
||||
)
|
||||
if _is_ear:
|
||||
# cut off the triangle
|
||||
triangles.append((prev.point, ear.point, next.point))
|
||||
remove_node(ear)
|
||||
|
||||
# skipping the next vertex leads to less sliver triangles
|
||||
ear = next.next
|
||||
stop = next.next
|
||||
continue
|
||||
|
||||
ear = next
|
||||
|
||||
# if we looped through the whole remaining polygon and can't find any more ears
|
||||
if ear is stop:
|
||||
# try filtering points and slicing again
|
||||
if not pass_:
|
||||
earcut_linked(
|
||||
filter_points(ear),
|
||||
triangles,
|
||||
min_x,
|
||||
min_y,
|
||||
inv_size,
|
||||
1,
|
||||
)
|
||||
|
||||
# if this didn't work, try curing all small self-intersections locally
|
||||
elif pass_ == 1:
|
||||
ear = cure_local_intersections(filter_points(ear), triangles)
|
||||
earcut_linked(ear, triangles, min_x, min_y, inv_size, 2)
|
||||
|
||||
# as a last resort, try splitting the remaining polygon into two
|
||||
elif pass_ == 2:
|
||||
split_ear_cut(ear, triangles, min_x, min_y, inv_size)
|
||||
break
|
||||
|
||||
|
||||
def is_ear(ear: Node) -> bool:
|
||||
"""check whether a polygon node forms a valid ear with adjacent nodes"""
|
||||
a: Node = ear.prev
|
||||
b: Node = ear
|
||||
c: Node = ear.next
|
||||
|
||||
if area(a, b, c) >= 0:
|
||||
return False # reflex, can't be an ear
|
||||
|
||||
# now make sure we don't have other points inside the potential ear
|
||||
ax = a.x
|
||||
bx = b.x
|
||||
cx = c.x
|
||||
ay = a.y
|
||||
by = b.y
|
||||
cy = c.y
|
||||
|
||||
# triangle bbox; min & max are calculated like this for speed
|
||||
x0 = min(ax, bx, cx)
|
||||
x1 = max(ax, bx, cx)
|
||||
y0 = min(ay, by, cy)
|
||||
y1 = max(ay, by, cy)
|
||||
p: Node = c.next
|
||||
|
||||
while p is not a:
|
||||
if (
|
||||
x0 <= p.x <= x1
|
||||
and y0 <= p.y <= y1
|
||||
and point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y)
|
||||
and area(p.prev, p, p.next) >= 0
|
||||
):
|
||||
return False
|
||||
p = p.next
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_ear_hashed(ear: Node, min_x: float, min_y: float, inv_size: float):
|
||||
a: Node = ear.prev
|
||||
b: Node = ear
|
||||
c: Node = ear.next
|
||||
|
||||
if area(a, b, c) >= 0:
|
||||
return False # reflex, can't be an ear
|
||||
|
||||
ax = a.x
|
||||
bx = b.x
|
||||
cx = c.x
|
||||
ay = a.y
|
||||
by = b.y
|
||||
cy = c.y
|
||||
|
||||
# triangle bbox; min & max are calculated like this for speed
|
||||
x0 = min(ax, bx, cx)
|
||||
x1 = max(ax, bx, cx)
|
||||
y0 = min(ay, by, cy)
|
||||
y1 = max(ay, by, cy)
|
||||
|
||||
# z-order range for the current triangle bbox;
|
||||
min_z = z_order(x0, y0, min_x, min_y, inv_size)
|
||||
max_z = z_order(x1, y1, min_x, min_y, inv_size)
|
||||
|
||||
p: Node = ear.prev_z
|
||||
n: Node = ear.next_z
|
||||
|
||||
# look for points inside the triangle in both directions
|
||||
while p and p.z >= min_z and n and n.z <= max_z:
|
||||
if (
|
||||
x0 <= p.x <= x1
|
||||
and y0 <= p.y <= y1
|
||||
and p is not a
|
||||
and p is not c
|
||||
and point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y)
|
||||
and area(p.prev, p, p.next) >= 0
|
||||
):
|
||||
return False
|
||||
p = p.prev_z
|
||||
|
||||
if (
|
||||
x0 <= n.x <= x1
|
||||
and y0 <= n.y <= y1
|
||||
and n is not a
|
||||
and n is not c
|
||||
and point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y)
|
||||
and area(n.prev, n, n.next) >= 0
|
||||
):
|
||||
return False
|
||||
n = n.next_z
|
||||
|
||||
# look for remaining points in decreasing z-order
|
||||
while p and p.z >= min_z:
|
||||
if (
|
||||
x0 <= p.x <= x1
|
||||
and y0 <= p.y <= y1
|
||||
and p is not a
|
||||
and p is not c
|
||||
and point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y)
|
||||
and area(p.prev, p, p.next) >= 0
|
||||
):
|
||||
return False
|
||||
p = p.prev_z
|
||||
|
||||
# look for remaining points in increasing z-order
|
||||
while n and n.z <= max_z:
|
||||
if (
|
||||
x0 <= n.x <= x1
|
||||
and y0 <= n.y <= y1
|
||||
and n is not a
|
||||
and n is not c
|
||||
and point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y)
|
||||
and area(n.prev, n, n.next) >= 0
|
||||
):
|
||||
return False
|
||||
n = n.next_z
|
||||
return True
|
||||
|
||||
|
||||
def get_leftmost(start: Node) -> Node:
|
||||
"""Find the leftmost node of a polygon ring"""
|
||||
p = start
|
||||
leftmost = start
|
||||
while True:
|
||||
if p.x < leftmost.x or (p.x == leftmost.x and p.y < leftmost.y):
|
||||
leftmost = p
|
||||
p = p.next
|
||||
if p is start:
|
||||
break
|
||||
return leftmost
|
||||
|
||||
|
||||
def point_in_triangle(
|
||||
ax: float,
|
||||
ay: float,
|
||||
bx: float,
|
||||
by: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
px: float,
|
||||
py: float,
|
||||
) -> bool:
|
||||
"""Check if a point lies within a convex triangle"""
|
||||
return (
|
||||
(cx - px) * (ay - py) >= (ax - px) * (cy - py)
|
||||
and (ax - px) * (by - py) >= (bx - px) * (ay - py)
|
||||
and (bx - px) * (cy - py) >= (cx - px) * (by - py)
|
||||
)
|
||||
|
||||
|
||||
def sector_contains_sector(m: Node, p: Node):
|
||||
"""Whether sector in vertex m contains sector in vertex p in the same
|
||||
coordinates.
|
||||
"""
|
||||
return area(m.prev, m, p.prev) < 0 and area(p.next, m, m.next) < 0
|
||||
|
||||
|
||||
def index_curve(start: Node, min_x: float, min_y: float, inv_size: float):
|
||||
"""Interlink polygon nodes in z-order"""
|
||||
p = start
|
||||
while True:
|
||||
if p.z == 0:
|
||||
p.z = z_order(p.x, p.y, min_x, min_y, inv_size)
|
||||
p.prev_z = p.prev
|
||||
p.next_z = p.next
|
||||
p = p.next
|
||||
if p is start:
|
||||
break
|
||||
|
||||
p.prev_z.next_z = None # type: ignore
|
||||
p.prev_z = None # type: ignore
|
||||
|
||||
sort_linked(p)
|
||||
|
||||
|
||||
def z_order(x0: float, y0: float, min_x: float, min_y: float, inv_size: float) -> int:
|
||||
"""Z-order of a point given coords and inverse of the longer side of data
|
||||
bbox.
|
||||
"""
|
||||
# coords are transformed into non-negative 15-bit integer range
|
||||
x = int((x0 - min_x) * inv_size)
|
||||
y = int((y0 - min_y) * inv_size)
|
||||
|
||||
x = (x | (x << 8)) & 0x00FF00FF
|
||||
x = (x | (x << 4)) & 0x0F0F0F0F
|
||||
x = (x | (x << 2)) & 0x33333333
|
||||
x = (x | (x << 1)) & 0x55555555
|
||||
|
||||
y = (y | (y << 8)) & 0x00FF00FF
|
||||
y = (y | (y << 4)) & 0x0F0F0F0F
|
||||
y = (y | (y << 2)) & 0x33333333
|
||||
y = (y | (y << 1)) & 0x55555555
|
||||
|
||||
return x | (y << 1)
|
||||
|
||||
|
||||
# Simon Tatham's linked list merge sort algorithm
|
||||
# http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html
|
||||
def sort_linked(head: Node) -> Node:
|
||||
in_size = 1
|
||||
tail: Node
|
||||
while True:
|
||||
p = head
|
||||
head = None # type: ignore
|
||||
tail = None # type: ignore
|
||||
num_merges = 0
|
||||
while p:
|
||||
num_merges += 1
|
||||
q = p
|
||||
p_size = 0
|
||||
for i in range(in_size):
|
||||
p_size += 1
|
||||
q = q.next_z
|
||||
if not q:
|
||||
break
|
||||
q_size = in_size
|
||||
while p_size > 0 or (q_size > 0 and q):
|
||||
if p_size != 0 and (q_size == 0 or not q or p.z <= q.z):
|
||||
e = p
|
||||
p = p.next_z
|
||||
p_size -= 1
|
||||
else:
|
||||
e = q
|
||||
q = q.next_z
|
||||
q_size -= 1
|
||||
|
||||
if tail:
|
||||
tail.next_z = e
|
||||
else:
|
||||
head = e
|
||||
e.prev_z = tail
|
||||
tail = e
|
||||
p = q
|
||||
tail.next_z = None # type: ignore
|
||||
in_size *= 2
|
||||
if num_merges <= 1:
|
||||
break
|
||||
return head
|
||||
|
||||
|
||||
def split_polygon(a: Node, b: Node) -> Node:
|
||||
"""Link two polygon vertices with a bridge.
|
||||
|
||||
If the vertices belong to the same ring, it splits polygon into two.
|
||||
If one belongs to the outer ring and another to a hole, it merges it into a
|
||||
single ring.
|
||||
"""
|
||||
a2 = Node(a.i, a.point)
|
||||
b2 = Node(b.i, b.point)
|
||||
an = a.next
|
||||
bp = b.prev
|
||||
|
||||
a.next = b
|
||||
b.prev = a
|
||||
|
||||
a2.next = an
|
||||
an.prev = a2
|
||||
|
||||
b2.next = a2
|
||||
a2.prev = b2
|
||||
|
||||
bp.next = b2
|
||||
b2.prev = bp
|
||||
|
||||
return b2
|
||||
|
||||
|
||||
# go through all polygon nodes and cure small local self-intersections
|
||||
def cure_local_intersections(start: Node, triangles: list[Sequence[Point]]) -> Node:
|
||||
p = start
|
||||
while True:
|
||||
a = p.prev
|
||||
b = p.next.next
|
||||
|
||||
if (
|
||||
not a == b # true equals
|
||||
and intersects(a, p, p.next, b)
|
||||
and locally_inside(a, b)
|
||||
and locally_inside(b, a)
|
||||
):
|
||||
triangles.append((a.point, p.point, b.point))
|
||||
# remove two nodes involved
|
||||
remove_node(p)
|
||||
remove_node(p.next)
|
||||
p = start = b
|
||||
|
||||
p = p.next
|
||||
if p is start:
|
||||
break
|
||||
return filter_points(p)
|
||||
|
||||
|
||||
def split_ear_cut(
|
||||
start: Node,
|
||||
triangles: list[Sequence[Point]],
|
||||
min_x: float,
|
||||
min_y: float,
|
||||
inv_size: float,
|
||||
) -> None:
|
||||
"""Try splitting polygon into two and triangulate them independently"""
|
||||
# look for a valid diagonal that divides the polygon into two
|
||||
a = start
|
||||
while True:
|
||||
b = a.next.next
|
||||
while b is not a.prev:
|
||||
if a.i != b.i and is_valid_diagonal(a, b):
|
||||
# split the polygon in two by the diagonal
|
||||
c = split_polygon(a, b)
|
||||
|
||||
# filter colinear points around the cuts
|
||||
a = filter_points(a, a.next)
|
||||
c = filter_points(c, c.next)
|
||||
|
||||
# run earcut on each half
|
||||
earcut_linked(a, triangles, min_x, min_y, inv_size, 0)
|
||||
earcut_linked(c, triangles, min_x, min_y, inv_size, 0)
|
||||
return
|
||||
b = b.next
|
||||
a = a.next
|
||||
if a is start:
|
||||
break
|
||||
|
||||
|
||||
# David Eberly's algorithm for finding a bridge between hole and outer polygon
|
||||
def find_hole_bridge(hole: Node, outer_node: Node) -> Node:
|
||||
p = outer_node
|
||||
hx = hole.x
|
||||
hy = hole.y
|
||||
qx = -math.inf
|
||||
m: Node = None # type: ignore
|
||||
# find a segment intersected by a ray from the hole's leftmost point to the left;
|
||||
# segment's endpoint with lesser x will be potential connection point
|
||||
while True:
|
||||
if p.y >= hy >= p.next.y != p.y:
|
||||
x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y)
|
||||
if hx >= x > qx:
|
||||
qx = x
|
||||
m = p if p.x < p.next.x else p.next
|
||||
if x == hx: # ??? use math.isclose
|
||||
# hole touches outer segment; pick leftmost endpoint
|
||||
return m
|
||||
p = p.next
|
||||
if p is outer_node:
|
||||
break
|
||||
|
||||
if m is None:
|
||||
return None
|
||||
|
||||
# look for points inside the triangle of hole point, segment intersection and endpoint;
|
||||
# if there are no points found, we have a valid connection;
|
||||
# otherwise choose the point of the minimum angle with the ray as connection point
|
||||
stop = m
|
||||
mx = m.x
|
||||
my = m.y
|
||||
tan_min = math.inf
|
||||
p = m
|
||||
|
||||
while True:
|
||||
if (
|
||||
hx >= p.x >= mx
|
||||
and hx != p.x
|
||||
and point_in_triangle(
|
||||
hx if hy < my else qx,
|
||||
hy,
|
||||
mx,
|
||||
my,
|
||||
qx if hy < my else hx,
|
||||
hy,
|
||||
p.x,
|
||||
p.y,
|
||||
)
|
||||
):
|
||||
tan = abs(hy - p.y) / (hx - p.x) # tangential
|
||||
|
||||
if locally_inside(p, hole) and (
|
||||
tan < tan_min
|
||||
or (
|
||||
tan == tan_min
|
||||
and (p.x > m.x or (p.x == m.x and sector_contains_sector(m, p)))
|
||||
)
|
||||
):
|
||||
m = p
|
||||
tan_min = tan
|
||||
|
||||
p = p.next
|
||||
if p is stop:
|
||||
break
|
||||
return m
|
||||
|
||||
|
||||
def locally_inside(a: Node, b: Node) -> bool:
|
||||
"""Check if a polygon diagonal is locally inside the polygon"""
|
||||
return (
|
||||
area(a, b, a.next) >= 0 and area(a, a.prev, b) >= 0
|
||||
if area(a.prev, a, a.next) < 0
|
||||
else area(a, b, a.prev) < 0 or area(a, a.next, b) < 0
|
||||
)
|
||||
|
||||
|
||||
def middle_inside(a: Node, b: Node) -> bool:
|
||||
"""Check if the middle point of a polygon diagonal is inside the polygon"""
|
||||
p = a
|
||||
inside = False
|
||||
px = (a.x + b.x) / 2
|
||||
py = (a.y + b.y) / 2
|
||||
while True:
|
||||
if (
|
||||
((p.y > py) != (p.next.y > py))
|
||||
and p.next.y != p.y
|
||||
and (px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x)
|
||||
):
|
||||
inside = not inside
|
||||
p = p.next
|
||||
if p is a:
|
||||
break
|
||||
return inside
|
||||
@@ -0,0 +1,718 @@
|
||||
# original code from package: gameobjects
|
||||
# Home-page: http://code.google.com/p/gameobjects/
|
||||
# Author: Will McGugan
|
||||
# Download-URL: http://code.google.com/p/gameobjects/downloads/list
|
||||
# Copyright (c) 2011-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Iterable, Iterator, TYPE_CHECKING, Optional
|
||||
import math
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from math import sin, cos, tan
|
||||
from itertools import chain
|
||||
|
||||
# The pure Python implementation can't import from ._ctypes or ezdxf.math!
|
||||
from ._vector import Vec3, X_AXIS, Y_AXIS, Z_AXIS, NULLVEC, Vec2
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.math import UVec
|
||||
|
||||
__all__ = ["Matrix44"]
|
||||
|
||||
|
||||
# removed array.array because array is optimized for space not speed, and space
|
||||
# optimization is not needed
|
||||
|
||||
|
||||
def floats(items: Iterable) -> list[float]:
|
||||
return [float(v) for v in items]
|
||||
|
||||
|
||||
class Matrix44:
|
||||
"""An optimized 4x4 `transformation matrix`_.
|
||||
|
||||
The utility functions for constructing transformations and transforming
|
||||
vectors and points assumes that vectors are stored as row vectors, meaning
|
||||
when multiplied, transformations are applied left to right (e.g. vAB
|
||||
transforms v by A then by B).
|
||||
|
||||
Matrix44 initialization:
|
||||
|
||||
- ``Matrix44()`` returns the identity matrix.
|
||||
- ``Matrix44(values)`` values is an iterable with the 16 components of
|
||||
the matrix.
|
||||
- ``Matrix44(row1, row2, row3, row4)`` four rows, each row with four
|
||||
values.
|
||||
|
||||
.. _transformation matrix: https://en.wikipedia.org/wiki/Transformation_matrix
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("_matrix",)
|
||||
_matrix: npt.NDArray[np.float64]
|
||||
|
||||
# fmt: off
|
||||
_identity = np.array([
|
||||
1.0, 0.0, 0.0, 0.0,
|
||||
0.0, 1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
], dtype=np.float64
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
def __init__(self, *args):
|
||||
"""
|
||||
Matrix44() is the identity matrix.
|
||||
|
||||
Matrix44(values) values is an iterable with the 16 components of the matrix.
|
||||
|
||||
Matrix44(row1, row2, row3, row4) four rows, each row with four values.
|
||||
|
||||
"""
|
||||
nargs = len(args)
|
||||
if nargs == 0:
|
||||
self._matrix = Matrix44._identity.copy()
|
||||
elif nargs == 1:
|
||||
self._matrix = np.array(args[0], dtype=np.float64)
|
||||
elif nargs == 4:
|
||||
self._matrix = np.array(list(chain(*args)), dtype=np.float64)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid count of arguments (4 row vectors or one "
|
||||
"list with 16 values)."
|
||||
)
|
||||
if self._matrix.shape != (16,):
|
||||
raise ValueError("Invalid matrix count")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Returns the representation string of the matrix in row-major order:
|
||||
``Matrix44((col0, col1, col2, col3), (...), (...), (...))``
|
||||
"""
|
||||
|
||||
def format_row(row):
|
||||
return "(%s)" % ", ".join(str(value) for value in row)
|
||||
|
||||
return "Matrix44(%s)" % ", ".join(format_row(row) for row in self.rows())
|
||||
|
||||
def get_2d_transformation(self) -> tuple[float, ...]:
|
||||
"""Returns a 2D transformation as a row-major matrix in a linear
|
||||
array (tuple).
|
||||
|
||||
A more correct transformation could be implemented like so:
|
||||
https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
|
||||
"""
|
||||
m = self._matrix
|
||||
return m[0], m[1], 0.0, m[4], m[5], 0.0, m[12], m[13], 1.0
|
||||
|
||||
@staticmethod
|
||||
def from_2d_transformation(components: Sequence[float]) -> Matrix44:
|
||||
"""Returns the :class:`Matrix44` class for an affine 2D (3x3) transformation
|
||||
matrix defined by 6 float values: m11, m12, m21, m22, m31, m32.
|
||||
"""
|
||||
if len(components) != 6:
|
||||
raise ValueError(
|
||||
"First 2 columns of a 3x3 matrix required: m11, m12, m21, m22, m31, m32"
|
||||
)
|
||||
|
||||
m44 = Matrix44()
|
||||
m = m44._matrix
|
||||
m[0] = components[0]
|
||||
m[1] = components[1]
|
||||
m[4] = components[2]
|
||||
m[5] = components[3]
|
||||
m[12] = components[4]
|
||||
m[13] = components[5]
|
||||
return m44
|
||||
|
||||
def get_row(self, row: int) -> tuple[float, ...]:
|
||||
"""Get row as list of four float values.
|
||||
|
||||
Args:
|
||||
row: row index [0 .. 3]
|
||||
|
||||
"""
|
||||
if 0 <= row < 4:
|
||||
index = row * 4
|
||||
return tuple(self._matrix[index : index + 4])
|
||||
else:
|
||||
raise IndexError(f"invalid row index: {row}")
|
||||
|
||||
def set_row(self, row: int, values: Sequence[float]) -> None:
|
||||
"""Sets the values in a row.
|
||||
|
||||
Args:
|
||||
row: row index [0 .. 3]
|
||||
values: iterable of four row values
|
||||
|
||||
"""
|
||||
if 0 <= row < 4:
|
||||
index = row * 4
|
||||
self._matrix[index : index + len(values)] = floats(values)
|
||||
else:
|
||||
raise IndexError(f"invalid row index: {row}")
|
||||
|
||||
def get_col(self, col: int) -> tuple[float, ...]:
|
||||
"""Returns a column as a tuple of four floats.
|
||||
|
||||
Args:
|
||||
col: column index [0 .. 3]
|
||||
"""
|
||||
if 0 <= col < 4:
|
||||
m = self._matrix
|
||||
return m[col], m[col + 4], m[col + 8], m[col + 12]
|
||||
else:
|
||||
raise IndexError(f"invalid row index: {col}")
|
||||
|
||||
def set_col(self, col: int, values: Sequence[float]):
|
||||
"""Sets the values in a column.
|
||||
|
||||
Args:
|
||||
col: column index [0 .. 3]
|
||||
values: iterable of four column values
|
||||
|
||||
"""
|
||||
if 0 <= col < 4:
|
||||
m = self._matrix
|
||||
a, b, c, d = values
|
||||
m[col] = float(a)
|
||||
m[col + 4] = float(b)
|
||||
m[col + 8] = float(c)
|
||||
m[col + 12] = float(d)
|
||||
else:
|
||||
raise IndexError(f"invalid row index: {col}")
|
||||
|
||||
def copy(self) -> Matrix44:
|
||||
"""Returns a copy of same type."""
|
||||
return self.__class__(self._matrix)
|
||||
|
||||
__copy__ = copy
|
||||
|
||||
@property
|
||||
def origin(self) -> Vec3:
|
||||
m = self._matrix
|
||||
return Vec3(m[12], m[13], m[14])
|
||||
|
||||
@origin.setter
|
||||
def origin(self, v: UVec) -> None:
|
||||
m = self._matrix
|
||||
m[12], m[13], m[14] = Vec3(v)
|
||||
|
||||
@property
|
||||
def ux(self) -> Vec3:
|
||||
return Vec3(self._matrix[0:3])
|
||||
|
||||
@property
|
||||
def uy(self) -> Vec3:
|
||||
return Vec3(self._matrix[4:7])
|
||||
|
||||
@property
|
||||
def uz(self) -> Vec3:
|
||||
return Vec3(self._matrix[8:11])
|
||||
|
||||
@property
|
||||
def is_cartesian(self) -> bool:
|
||||
"""Returns ``True`` if target coordinate system is a right handed
|
||||
orthogonal coordinate system.
|
||||
"""
|
||||
return self.uy.cross(self.uz).normalize().isclose(self.ux.normalize())
|
||||
|
||||
@property
|
||||
def is_orthogonal(self) -> bool:
|
||||
"""Returns ``True`` if target coordinate system has orthogonal axis.
|
||||
|
||||
Does not check for left- or right handed orientation, any orientation
|
||||
of the axis valid.
|
||||
|
||||
"""
|
||||
ux = self.ux.normalize()
|
||||
uy = self.uy.normalize()
|
||||
uz = self.uz.normalize()
|
||||
return (
|
||||
abs(ux.dot(uy)) <= 1e-9
|
||||
and abs(ux.dot(uz)) <= 1e-9
|
||||
and abs(uy.dot(uz)) <= 1e-9
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def scale(
|
||||
cls, sx: float, sy: Optional[float] = None, sz: Optional[float] = None
|
||||
) -> Matrix44:
|
||||
"""Returns a scaling transformation matrix. If `sy` is ``None``,
|
||||
`sy` = `sx`, and if `sz` is ``None`` `sz` = `sx`.
|
||||
|
||||
"""
|
||||
if sy is None:
|
||||
sy = sx
|
||||
if sz is None:
|
||||
sz = sx
|
||||
# fmt: off
|
||||
m = cls([
|
||||
float(sx), 0., 0., 0.,
|
||||
0., float(sy), 0., 0.,
|
||||
0., 0., float(sz), 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
return m
|
||||
|
||||
@classmethod
|
||||
def translate(cls, dx: float, dy: float, dz: float) -> Matrix44:
|
||||
"""Returns a translation matrix for translation vector (dx, dy, dz)."""
|
||||
# fmt: off
|
||||
return cls([
|
||||
1., 0., 0., 0.,
|
||||
0., 1., 0., 0.,
|
||||
0., 0., 1., 0.,
|
||||
float(dx), float(dy), float(dz), 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def x_rotate(cls, angle: float) -> Matrix44:
|
||||
"""Returns a rotation matrix about the x-axis.
|
||||
|
||||
Args:
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
cos_a = cos(angle)
|
||||
sin_a = sin(angle)
|
||||
# fmt: off
|
||||
return cls([
|
||||
1., 0., 0., 0.,
|
||||
0., cos_a, sin_a, 0.,
|
||||
0., -sin_a, cos_a, 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def y_rotate(cls, angle: float) -> Matrix44:
|
||||
"""Returns a rotation matrix about the y-axis.
|
||||
|
||||
Args:
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
cos_a = cos(angle)
|
||||
sin_a = sin(angle)
|
||||
# fmt: off
|
||||
return cls([
|
||||
cos_a, 0., -sin_a, 0.,
|
||||
0., 1., 0., 0.,
|
||||
sin_a, 0., cos_a, 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def z_rotate(cls, angle: float) -> Matrix44:
|
||||
"""Returns a rotation matrix about the z-axis.
|
||||
|
||||
Args:
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
cos_a = cos(angle)
|
||||
sin_a = sin(angle)
|
||||
# fmt: off
|
||||
return cls([
|
||||
cos_a, sin_a, 0., 0.,
|
||||
-sin_a, cos_a, 0., 0.,
|
||||
0., 0., 1., 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def axis_rotate(cls, axis: UVec, angle: float) -> Matrix44:
|
||||
"""Returns a rotation matrix about an arbitrary `axis`.
|
||||
|
||||
Args:
|
||||
axis: rotation axis as ``(x, y, z)`` tuple or :class:`Vec3` object
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
c = cos(angle)
|
||||
s = sin(angle)
|
||||
omc = 1.0 - c
|
||||
x, y, z = Vec3(axis).normalize()
|
||||
# fmt: off
|
||||
return cls([
|
||||
x * x * omc + c, y * x * omc + z * s, x * z * omc - y * s, 0.,
|
||||
x * y * omc - z * s, y * y * omc + c, y * z * omc + x * s, 0.,
|
||||
x * z * omc + y * s, y * z * omc - x * s, z * z * omc + c, 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def xyz_rotate(cls, angle_x: float, angle_y: float, angle_z: float) -> Matrix44:
|
||||
"""Returns a rotation matrix for rotation about each axis.
|
||||
|
||||
Args:
|
||||
angle_x: rotation angle about x-axis in radians
|
||||
angle_y: rotation angle about y-axis in radians
|
||||
angle_z: rotation angle about z-axis in radians
|
||||
|
||||
"""
|
||||
cx = cos(angle_x)
|
||||
sx = sin(angle_x)
|
||||
cy = cos(angle_y)
|
||||
sy = sin(angle_y)
|
||||
cz = cos(angle_z)
|
||||
sz = sin(angle_z)
|
||||
|
||||
sxsy = sx * sy
|
||||
cxsy = cx * sy
|
||||
# fmt: off
|
||||
return cls([
|
||||
cy * cz, sxsy * cz + cx * sz, -cxsy * cz + sx * sz, 0.,
|
||||
-cy * sz, -sxsy * sz + cx * cz, cxsy * sz + sx * cz, 0.,
|
||||
sy, -sx * cy, cx * cy, 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def shear_xy(cls, angle_x: float = 0, angle_y: float = 0) -> Matrix44:
|
||||
"""Returns a translation matrix for shear mapping (visually similar
|
||||
to slanting) in the xy-plane.
|
||||
|
||||
Args:
|
||||
angle_x: slanting angle in x direction in radians
|
||||
angle_y: slanting angle in y direction in radians
|
||||
|
||||
"""
|
||||
tx = math.tan(angle_x)
|
||||
ty = math.tan(angle_y)
|
||||
# fmt: off
|
||||
return cls([
|
||||
1., ty, 0., 0.,
|
||||
tx, 1., 0., 0.,
|
||||
0., 0., 1., 0.,
|
||||
0., 0., 0., 1.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def perspective_projection(
|
||||
cls,
|
||||
left: float,
|
||||
right: float,
|
||||
top: float,
|
||||
bottom: float,
|
||||
near: float,
|
||||
far: float,
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix for a 2D projection.
|
||||
|
||||
Args:
|
||||
left: Coordinate of left of screen
|
||||
right: Coordinate of right of screen
|
||||
top: Coordinate of the top of the screen
|
||||
bottom: Coordinate of the bottom of the screen
|
||||
near: Coordinate of the near clipping plane
|
||||
far: Coordinate of the far clipping plane
|
||||
|
||||
"""
|
||||
# fmt: off
|
||||
return cls([
|
||||
(2. * near) / (right - left), 0., 0., 0.,
|
||||
0., (2. * near) / (top - bottom), 0., 0.,
|
||||
(right + left) / (right - left), (top + bottom) / (top - bottom),
|
||||
-((far + near) / (far - near)), -1.,
|
||||
0., 0., -((2. * far * near) / (far - near)), 0.
|
||||
])
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def perspective_projection_fov(
|
||||
cls, fov: float, aspect: float, near: float, far: float
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix for a 2D projection.
|
||||
|
||||
Args:
|
||||
fov: The field of view (in radians)
|
||||
aspect: The aspect ratio of the screen (width / height)
|
||||
near: Coordinate of the near clipping plane
|
||||
far: Coordinate of the far clipping plane
|
||||
|
||||
"""
|
||||
vrange = near * tan(fov / 2.0)
|
||||
left = -vrange * aspect
|
||||
right = vrange * aspect
|
||||
bottom = -vrange
|
||||
top = vrange
|
||||
return cls.perspective_projection(left, right, bottom, top, near, far)
|
||||
|
||||
@staticmethod
|
||||
def chain(*matrices: Matrix44) -> Matrix44:
|
||||
"""Compose a transformation matrix from one or more `matrices`."""
|
||||
transformation = Matrix44()
|
||||
for matrix in matrices:
|
||||
transformation *= matrix
|
||||
return transformation
|
||||
|
||||
@staticmethod
|
||||
def ucs(
|
||||
ux: Vec3 = X_AXIS,
|
||||
uy: Vec3 = Y_AXIS,
|
||||
uz: Vec3 = Z_AXIS,
|
||||
origin: Vec3 = NULLVEC,
|
||||
) -> Matrix44:
|
||||
"""Returns a matrix for coordinate transformation from WCS to UCS.
|
||||
For transformation from UCS to WCS, transpose the returned matrix.
|
||||
|
||||
Args:
|
||||
ux: x-axis for UCS as unit vector
|
||||
uy: y-axis for UCS as unit vector
|
||||
uz: z-axis for UCS as unit vector
|
||||
origin: UCS origin as location vector
|
||||
|
||||
"""
|
||||
ux_x, ux_y, ux_z = ux
|
||||
uy_x, uy_y, uy_z = uy
|
||||
uz_x, uz_y, uz_z = uz
|
||||
or_x, or_y, or_z = origin
|
||||
# fmt: off
|
||||
return Matrix44((
|
||||
ux_x, ux_y, ux_z, 0,
|
||||
uy_x, uy_y, uy_z, 0,
|
||||
uz_x, uz_y, uz_z, 0,
|
||||
or_x, or_y, or_z, 1,
|
||||
))
|
||||
# fmt: on
|
||||
|
||||
def __setitem__(self, index: tuple[int, int], value: float):
|
||||
"""Set (row, column) element."""
|
||||
row, col = index
|
||||
if 0 <= row < 4 and 0 <= col < 4:
|
||||
self._matrix[row * 4 + col] = float(value)
|
||||
else:
|
||||
raise IndexError(f"index out of range: {index}")
|
||||
|
||||
def __getitem__(self, index: tuple[int, int]):
|
||||
"""Get (row, column) element."""
|
||||
row, col = index
|
||||
if 0 <= row < 4 and 0 <= col < 4:
|
||||
return self._matrix[row * 4 + col]
|
||||
else:
|
||||
raise IndexError(f"index out of range: {index}")
|
||||
|
||||
def __iter__(self) -> Iterator[float]:
|
||||
"""Iterates over all matrix values."""
|
||||
return iter(self._matrix)
|
||||
|
||||
def __mul__(self, other: Matrix44) -> Matrix44:
|
||||
"""Returns a new matrix as result of the matrix multiplication with
|
||||
another matrix.
|
||||
"""
|
||||
m1 = self._matrix.reshape(4, 4)
|
||||
m2 = other._matrix.reshape(4, 4)
|
||||
result = np.matmul(m1, m2)
|
||||
return self.__class__(np.ravel(result))
|
||||
|
||||
# __matmul__ = __mul__ does not work!
|
||||
|
||||
def __matmul__(self, other: Matrix44) -> Matrix44:
|
||||
"""Returns a new matrix as result of the matrix multiplication with
|
||||
another matrix.
|
||||
"""
|
||||
m1 = self._matrix.reshape(4, 4)
|
||||
m2 = other._matrix.reshape(4, 4)
|
||||
result = np.matmul(m1, m2)
|
||||
return self.__class__(np.ravel(result))
|
||||
|
||||
def __imul__(self, other: Matrix44) -> Matrix44:
|
||||
"""Inplace multiplication with another matrix."""
|
||||
m1 = self._matrix.reshape(4, 4)
|
||||
m2 = other._matrix.reshape(4, 4)
|
||||
result = np.matmul(m1, m2)
|
||||
self._matrix = np.ravel(result)
|
||||
return self
|
||||
|
||||
def rows(self) -> Iterator[tuple[float, ...]]:
|
||||
"""Iterate over rows as 4-tuples."""
|
||||
return (self.get_row(index) for index in (0, 1, 2, 3))
|
||||
|
||||
def columns(self) -> Iterator[tuple[float, ...]]:
|
||||
"""Iterate over columns as 4-tuples."""
|
||||
return (self.get_col(index) for index in (0, 1, 2, 3))
|
||||
|
||||
def transform(self, vector: UVec) -> Vec3:
|
||||
"""Returns a transformed vertex."""
|
||||
m = self._matrix
|
||||
x, y, z = Vec3(vector)
|
||||
# fmt: off
|
||||
return Vec3(
|
||||
x * m[0] + y * m[4] + z * m[8] + m[12],
|
||||
x * m[1] + y * m[5] + z * m[9] + m[13],
|
||||
x * m[2] + y * m[6] + z * m[10] + m[14]
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
def transform_direction(self, vector: UVec, normalize=False) -> Vec3:
|
||||
"""Returns a transformed direction vector without translation."""
|
||||
m = self._matrix
|
||||
x, y, z = Vec3(vector)
|
||||
# fmt: off
|
||||
v = Vec3(
|
||||
x * m[0] + y * m[4] + z * m[8],
|
||||
x * m[1] + y * m[5] + z * m[9],
|
||||
x * m[2] + y * m[6] + z * m[10]
|
||||
)
|
||||
# fmt: on
|
||||
return v.normalize() if normalize else v
|
||||
|
||||
ocs_to_wcs = transform_direction
|
||||
|
||||
def transform_vertices(self, vectors: Iterable[UVec]) -> Iterator[Vec3]:
|
||||
"""Returns an iterable of transformed vertices."""
|
||||
# fmt: off
|
||||
(
|
||||
m0, m1, m2, m3,
|
||||
m4, m5, m6, m7,
|
||||
m8, m9, m10, m11,
|
||||
m12, m13, m14, m15,
|
||||
) = self._matrix
|
||||
# fmt: on
|
||||
for vector in vectors:
|
||||
x, y, z = Vec3(vector)
|
||||
# fmt: off
|
||||
yield Vec3(
|
||||
x * m0 + y * m4 + z * m8 + m12,
|
||||
x * m1 + y * m5 + z * m9 + m13,
|
||||
x * m2 + y * m6 + z * m10 + m14
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
def fast_2d_transform(self, points: Iterable[UVec]) -> Iterator[Vec2]:
|
||||
"""Fast transformation of 2D points. For 3D input points the z-axis will be
|
||||
ignored. This only works reliable if only 2D transformations have been applied
|
||||
to the 4x4 matrix!
|
||||
|
||||
Profiling results - speed gains over :meth:`transform_vertices`:
|
||||
|
||||
- pure Python code: ~1.6x
|
||||
- Python with C-extensions: less than 1.1x
|
||||
- PyPy 3.8: ~4.3x
|
||||
|
||||
But speed isn't everything, returning the processed input points as :class:`Vec2`
|
||||
instances is another advantage.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
|
||||
"""
|
||||
m = self._matrix
|
||||
m0 = m[0]
|
||||
m1 = m[1]
|
||||
m4 = m[4]
|
||||
m5 = m[5]
|
||||
m12 = m[12]
|
||||
m13 = m[13]
|
||||
for pnt in points:
|
||||
v = Vec2(pnt)
|
||||
x = v.x
|
||||
y = v.y
|
||||
yield Vec2(x * m0 + y * m4 + m12, x * m1 + y * m5 + m13)
|
||||
|
||||
def transform_array_inplace(self, array: np.ndarray, ndim: int) -> None:
|
||||
"""Transforms a numpy array inplace, the argument `ndim` defines the dimensions
|
||||
to transform, this allows 2D/3D transformation on arrays with more columns
|
||||
e.g. a polyline array which stores points as (x, y, start_width, end_width,
|
||||
bulge) values.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
|
||||
"""
|
||||
# This implementation exist only for compatibility to the Cython implementation!
|
||||
# This version is 3.4x faster than the Cython version of Matrix44.fast_2d_transform()
|
||||
# for larger point arrays but 10.5x slower than the Cython version of this method.
|
||||
if ndim == 2:
|
||||
m = np.array(self.get_2d_transformation(), dtype=np.float64)
|
||||
m.shape = (3, 3)
|
||||
elif ndim == 3:
|
||||
m = np.array(self._matrix, dtype=np.float64)
|
||||
m.shape = (4, 4)
|
||||
else:
|
||||
raise ValueError("ndim has to be 2 or 3")
|
||||
|
||||
v = np.matmul(
|
||||
np.concatenate((array[:, :ndim], np.ones((array.shape[0], 1))), axis=1), m
|
||||
)
|
||||
array[:, :ndim] = v[:, :ndim].copy()
|
||||
|
||||
def transform_directions(
|
||||
self, vectors: Iterable[UVec], normalize=False
|
||||
) -> Iterator[Vec3]:
|
||||
"""Returns an iterable of transformed direction vectors without
|
||||
translation.
|
||||
|
||||
"""
|
||||
m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, *_ = self._matrix
|
||||
for vector in vectors:
|
||||
x, y, z = Vec3(vector)
|
||||
# fmt: off
|
||||
v = Vec3(
|
||||
x * m0 + y * m4 + z * m8,
|
||||
x * m1 + y * m5 + z * m9,
|
||||
x * m2 + y * m6 + z * m10
|
||||
)
|
||||
# fmt: on
|
||||
yield v.normalize() if normalize else v
|
||||
|
||||
def ucs_vertex_from_wcs(self, wcs: Vec3) -> Vec3:
|
||||
"""Returns an UCS vector from WCS vertex.
|
||||
|
||||
Works only if matrix is used as cartesian UCS without scaling.
|
||||
|
||||
(internal API)
|
||||
|
||||
"""
|
||||
return self.ucs_direction_from_wcs(wcs - self.origin)
|
||||
|
||||
def ucs_direction_from_wcs(self, wcs: Vec3) -> Vec3:
|
||||
"""Returns UCS direction vector from WCS direction.
|
||||
|
||||
Works only if matrix is used as cartesian UCS without scaling.
|
||||
|
||||
(internal API)
|
||||
|
||||
"""
|
||||
m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, *_ = self._matrix
|
||||
x, y, z = wcs
|
||||
# fmt: off
|
||||
return Vec3(
|
||||
x * m0 + y * m1 + z * m2,
|
||||
x * m4 + y * m5 + z * m6,
|
||||
x * m8 + y * m9 + z * m10,
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
ocs_from_wcs = ucs_direction_from_wcs
|
||||
|
||||
def transpose(self) -> None:
|
||||
"""Swaps the rows for columns inplace."""
|
||||
m = self._matrix.reshape(4, 4)
|
||||
self._matrix = np.ravel(m.T)
|
||||
|
||||
def determinant(self) -> float:
|
||||
"""Returns determinant."""
|
||||
return np.linalg.det(self._matrix.reshape(4, 4))
|
||||
|
||||
def inverse(self) -> None:
|
||||
"""Calculates the inverse of the matrix.
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: if matrix has no inverse.
|
||||
|
||||
"""
|
||||
try:
|
||||
inverse = np.linalg.inv(self._matrix.reshape(4, 4))
|
||||
except np.linalg.LinAlgError:
|
||||
raise ZeroDivisionError
|
||||
self._matrix = np.ravel(inverse)
|
||||
@@ -0,0 +1,825 @@
|
||||
# Copyright (c) 2018-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Tuple,
|
||||
Iterable,
|
||||
Sequence,
|
||||
TYPE_CHECKING,
|
||||
Iterator,
|
||||
Optional,
|
||||
)
|
||||
from functools import partial
|
||||
import math
|
||||
import random
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.math import UVec, AnyVec
|
||||
|
||||
ABS_TOL = 1e-12
|
||||
isclose = partial(math.isclose, abs_tol=ABS_TOL)
|
||||
|
||||
__all__ = ["Vec3", "Vec2"]
|
||||
|
||||
|
||||
class Vec3:
|
||||
"""Immutable 3D vector class.
|
||||
|
||||
This class is optimized for universality not for speed.
|
||||
Immutable means you can't change (x, y, z) components after initialization::
|
||||
|
||||
v1 = Vec3(1, 2, 3)
|
||||
v2 = v1
|
||||
v2.z = 7 # this is not possible, raises AttributeError
|
||||
v2 = Vec3(v2.x, v2.y, 7) # this creates a new Vec3() object
|
||||
assert v1.z == 3 # and v1 remains unchanged
|
||||
|
||||
|
||||
:class:`Vec3` initialization:
|
||||
|
||||
- ``Vec3()``, returns ``Vec3(0, 0, 0)``
|
||||
- ``Vec3((x, y))``, returns ``Vec3(x, y, 0)``
|
||||
- ``Vec3((x, y, z))``, returns ``Vec3(x, y, z)``
|
||||
- ``Vec3(x, y)``, returns ``Vec3(x, y, 0)``
|
||||
- ``Vec3(x, y, z)``, returns ``Vec3(x, y, z)``
|
||||
|
||||
Addition, subtraction, scalar multiplication and scalar division left and
|
||||
right-handed are supported::
|
||||
|
||||
v = Vec3(1, 2, 3)
|
||||
v + (1, 2, 3) == Vec3(2, 4, 6)
|
||||
(1, 2, 3) + v == Vec3(2, 4, 6)
|
||||
v - (1, 2, 3) == Vec3(0, 0, 0)
|
||||
(1, 2, 3) - v == Vec3(0, 0, 0)
|
||||
v * 3 == Vec3(3, 6, 9)
|
||||
3 * v == Vec3(3, 6, 9)
|
||||
Vec3(3, 6, 9) / 3 == Vec3(1, 2, 3)
|
||||
-Vec3(1, 2, 3) == (-1, -2, -3)
|
||||
|
||||
Comparison between vectors and vectors or tuples is supported::
|
||||
|
||||
Vec3(1, 2, 3) < Vec3 (2, 2, 2)
|
||||
(1, 2, 3) < tuple(Vec3(2, 2, 2)) # conversion necessary
|
||||
Vec3(1, 2, 3) == (1, 2, 3)
|
||||
|
||||
bool(Vec3(1, 2, 3)) is True
|
||||
bool(Vec3(0, 0, 0)) is False
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ["_x", "_y", "_z"]
|
||||
|
||||
def __init__(self, *args):
|
||||
self._x, self._y, self._z = self.decompose(*args)
|
||||
|
||||
@property
|
||||
def x(self) -> float:
|
||||
"""x-axis value"""
|
||||
return self._x
|
||||
|
||||
@property
|
||||
def y(self) -> float:
|
||||
"""y-axis value"""
|
||||
return self._y
|
||||
|
||||
@property
|
||||
def z(self) -> float:
|
||||
"""z-axis value"""
|
||||
return self._z
|
||||
|
||||
@property
|
||||
def xy(self) -> Vec3:
|
||||
"""Vec3 as ``(x, y, 0)``, projected on the xy-plane."""
|
||||
return self.__class__(self._x, self._y)
|
||||
|
||||
@property
|
||||
def xyz(self) -> tuple[float, float, float]:
|
||||
"""Vec3 as ``(x, y, z)`` tuple."""
|
||||
return self._x, self._y, self._z
|
||||
|
||||
@property
|
||||
def vec2(self) -> Vec2:
|
||||
"""Real 2D vector as :class:`Vec2` object."""
|
||||
return Vec2((self._x, self._y))
|
||||
|
||||
def replace(
|
||||
self,
|
||||
x: Optional[float] = None,
|
||||
y: Optional[float] = None,
|
||||
z: Optional[float] = None,
|
||||
) -> Vec3:
|
||||
"""Returns a copy of vector with replaced x-, y- and/or z-axis."""
|
||||
if x is None:
|
||||
x = self._x
|
||||
if y is None:
|
||||
y = self._y
|
||||
if z is None:
|
||||
z = self._z
|
||||
return self.__class__(x, y, z)
|
||||
|
||||
def round(self, ndigits=None) -> Vec3:
|
||||
"""Returns a new vector where all components are rounded to `ndigits`.
|
||||
|
||||
Uses standard Python :func:`round` function for rounding.
|
||||
"""
|
||||
return self.__class__(
|
||||
round(self._x, ndigits),
|
||||
round(self._y, ndigits),
|
||||
round(self._z, ndigits),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list(cls, items: Iterable[UVec]) -> list[Vec3]:
|
||||
"""Returns a list of :class:`Vec3` objects."""
|
||||
return list(cls.generate(items))
|
||||
|
||||
@classmethod
|
||||
def tuple(cls, items: Iterable[UVec]) -> Sequence[Vec3]:
|
||||
"""Returns a tuple of :class:`Vec3` objects."""
|
||||
return tuple(cls.generate(items))
|
||||
|
||||
@classmethod
|
||||
def generate(cls, items: Iterable[UVec]) -> Iterator[Vec3]:
|
||||
"""Returns an iterable of :class:`Vec3` objects."""
|
||||
return (cls(item) for item in items)
|
||||
|
||||
@classmethod
|
||||
def from_angle(cls, angle: float, length: float = 1.0) -> Vec3:
|
||||
"""Returns a :class:`Vec3` object from `angle` in radians in the
|
||||
xy-plane, z-axis = ``0``.
|
||||
"""
|
||||
return cls(math.cos(angle) * length, math.sin(angle) * length, 0.0)
|
||||
|
||||
@classmethod
|
||||
def from_deg_angle(cls, angle: float, length: float = 1.0) -> Vec3:
|
||||
"""Returns a :class:`Vec3` object from `angle` in degrees in the
|
||||
xy-plane, z-axis = ``0``.
|
||||
"""
|
||||
return cls.from_angle(math.radians(angle), length)
|
||||
|
||||
@staticmethod
|
||||
def decompose(*args) -> Tuple[float, float, float]: # cannot use "tuple" here!
|
||||
"""Converts input into a (x, y, z) tuple.
|
||||
|
||||
Valid arguments are:
|
||||
|
||||
- no args: ``decompose()`` returns (0, 0, 0)
|
||||
- 1 arg: ``decompose(arg)``, `arg` is tuple or list, tuple has to be
|
||||
(x, y[, z]): ``decompose((x, y))`` returns (x, y, 0.)
|
||||
- 2 args: ``decompose(x, y)`` returns (x, y, 0)
|
||||
- 3 args: ``decompose(x, y, z)`` returns (x, y, z)
|
||||
|
||||
Returns:
|
||||
(x, y, z) tuple
|
||||
|
||||
(internal API)
|
||||
|
||||
"""
|
||||
length = len(args)
|
||||
if length == 0:
|
||||
return 0.0, 0.0, 0.0
|
||||
elif length == 1:
|
||||
data = args[0]
|
||||
if isinstance(data, Vec3):
|
||||
return data._x, data._y, data._z
|
||||
else:
|
||||
length = len(data)
|
||||
if length == 2:
|
||||
x, y = data
|
||||
z = 0.0
|
||||
elif length == 3:
|
||||
x, y, z = data
|
||||
else:
|
||||
raise TypeError
|
||||
return float(x), float(y), float(z)
|
||||
elif length == 2:
|
||||
x, y = args
|
||||
return float(x), float(y), 0.0
|
||||
elif length == 3:
|
||||
x, y, z = args
|
||||
return float(x), float(y), float(z)
|
||||
raise TypeError
|
||||
|
||||
@classmethod
|
||||
def random(cls, length: float = 1) -> Vec3:
|
||||
"""Returns a random vector."""
|
||||
x = random.uniform(-1, 1)
|
||||
y = random.uniform(-1, 1)
|
||||
z = random.uniform(-1, 1)
|
||||
return Vec3(x, y, z).normalize(length)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return ``'(x, y, z)'`` as string."""
|
||||
return "({0.x}, {0.y}, {0.z})".format(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return ``'Vec3(x, y, z)'`` as string."""
|
||||
return "Vec3" + self.__str__()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Returns always ``3``."""
|
||||
return 3
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Returns hash value of vector, enables the usage of vector as key in
|
||||
``set`` and ``dict``.
|
||||
"""
|
||||
return hash(self.xyz)
|
||||
|
||||
def copy(self) -> Vec3:
|
||||
"""Returns a copy of vector as :class:`Vec3` object."""
|
||||
return self # immutable!
|
||||
|
||||
__copy__ = copy
|
||||
|
||||
def __deepcopy__(self, memodict: dict) -> Vec3:
|
||||
""":func:`copy.deepcopy` support."""
|
||||
return self # immutable!
|
||||
|
||||
def __getitem__(self, index: int) -> float:
|
||||
"""Support for indexing:
|
||||
|
||||
- v[0] is v.x
|
||||
- v[1] is v.y
|
||||
- v[2] is v.z
|
||||
|
||||
"""
|
||||
if isinstance(index, slice):
|
||||
raise TypeError("slicing not supported")
|
||||
if index == 0:
|
||||
return self._x
|
||||
elif index == 1:
|
||||
return self._y
|
||||
elif index == 2:
|
||||
return self._z
|
||||
else:
|
||||
raise IndexError(f"invalid index {index}")
|
||||
|
||||
def __iter__(self) -> Iterator[float]:
|
||||
"""Returns iterable of x-, y- and z-axis."""
|
||||
yield self._x
|
||||
yield self._y
|
||||
yield self._z
|
||||
|
||||
def __abs__(self) -> float:
|
||||
"""Returns length (magnitude) of vector."""
|
||||
return self.magnitude
|
||||
|
||||
@property
|
||||
def magnitude(self) -> float:
|
||||
"""Length of vector."""
|
||||
return self.magnitude_square**0.5
|
||||
|
||||
@property
|
||||
def magnitude_xy(self) -> float:
|
||||
"""Length of vector in the xy-plane."""
|
||||
return math.hypot(self._x, self._y)
|
||||
|
||||
@property
|
||||
def magnitude_square(self) -> float:
|
||||
"""Square length of vector."""
|
||||
x, y, z = self._x, self._y, self._z
|
||||
return x * x + y * y + z * z
|
||||
|
||||
@property
|
||||
def is_null(self) -> bool:
|
||||
"""``True`` if all components are close to zero: ``Vec3(0, 0, 0)``.
|
||||
Has a fixed absolute testing tolerance of 1e-12!
|
||||
"""
|
||||
return (
|
||||
abs(self._x) <= ABS_TOL
|
||||
and abs(self._y) <= ABS_TOL
|
||||
and abs(self._z) <= ABS_TOL
|
||||
)
|
||||
|
||||
def is_parallel(
|
||||
self, other: Vec3, *, rel_tol: float = 1e-9, abs_tol: float = 1e-12
|
||||
) -> bool:
|
||||
"""Returns ``True`` if `self` and `other` are parallel to vectors."""
|
||||
v1 = self.normalize()
|
||||
v2 = other.normalize()
|
||||
return v1.isclose(v2, rel_tol=rel_tol, abs_tol=abs_tol) or v1.isclose(
|
||||
-v2, rel_tol=rel_tol, abs_tol=abs_tol
|
||||
)
|
||||
|
||||
@property
|
||||
def spatial_angle(self) -> float:
|
||||
"""Spatial angle between vector and x-axis in radians."""
|
||||
return math.acos(X_AXIS.dot(self.normalize()))
|
||||
|
||||
@property
|
||||
def spatial_angle_deg(self) -> float:
|
||||
"""Spatial angle between vector and x-axis in degrees."""
|
||||
return math.degrees(self.spatial_angle)
|
||||
|
||||
@property
|
||||
def angle(self) -> float:
|
||||
"""Angle between vector and x-axis in the xy-plane in radians."""
|
||||
return math.atan2(self._y, self._x)
|
||||
|
||||
@property
|
||||
def angle_deg(self) -> float:
|
||||
"""Returns angle of vector and x-axis in the xy-plane in degrees."""
|
||||
return math.degrees(self.angle)
|
||||
|
||||
def orthogonal(self, ccw: bool = True) -> Vec3:
|
||||
"""Returns orthogonal 2D vector, z-axis is unchanged.
|
||||
|
||||
Args:
|
||||
ccw: counter-clockwise if ``True`` else clockwise
|
||||
|
||||
"""
|
||||
return (
|
||||
self.__class__(-self._y, self._x, self._z)
|
||||
if ccw
|
||||
else self.__class__(self._y, -self._x, self._z)
|
||||
)
|
||||
|
||||
def lerp(self, other: UVec, factor=0.5) -> Vec3:
|
||||
"""Returns linear interpolation between `self` and `other`.
|
||||
|
||||
Args:
|
||||
other: end point as :class:`Vec3` compatible object
|
||||
factor: interpolation factor (0 = self, 1 = other,
|
||||
0.5 = mid point)
|
||||
|
||||
"""
|
||||
d = (self.__class__(other) - self) * float(factor)
|
||||
return self.__add__(d)
|
||||
|
||||
def project(self, other: UVec) -> Vec3:
|
||||
"""Returns projected vector of `other` onto `self`."""
|
||||
uv = self.normalize()
|
||||
return uv * uv.dot(other)
|
||||
|
||||
def normalize(self, length: float = 1.0) -> Vec3:
|
||||
"""Returns normalized vector, optional scaled by `length`."""
|
||||
return self.__mul__(length / self.magnitude)
|
||||
|
||||
def reversed(self) -> Vec3:
|
||||
"""Returns negated vector (-`self`)."""
|
||||
return self.__class__(-self._x, -self._y, -self._z)
|
||||
|
||||
__neg__ = reversed
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Returns ``True`` if vector is not (0, 0, 0)."""
|
||||
return not self.is_null
|
||||
|
||||
def isclose(
|
||||
self, other: UVec, *, rel_tol: float = 1e-9, abs_tol: float = 1e-12
|
||||
) -> bool:
|
||||
"""Returns ``True`` if `self` is close to `other`.
|
||||
Uses :func:`math.isclose` to compare all axis.
|
||||
|
||||
Learn more about the :func:`math.isclose` function in
|
||||
`PEP 485 <https://www.python.org/dev/peps/pep-0485/>`_.
|
||||
|
||||
"""
|
||||
x, y, z = self.decompose(other)
|
||||
return (
|
||||
math.isclose(self._x, x, rel_tol=rel_tol, abs_tol=abs_tol)
|
||||
and math.isclose(self._y, y, rel_tol=rel_tol, abs_tol=abs_tol)
|
||||
and math.isclose(self._z, z, rel_tol=rel_tol, abs_tol=abs_tol)
|
||||
)
|
||||
|
||||
def __eq__(self, other: UVec) -> bool:
|
||||
"""Equal operator.
|
||||
|
||||
Args:
|
||||
other: :class:`Vec3` compatible object
|
||||
"""
|
||||
if not isinstance(other, Vec3):
|
||||
other = Vec3(other)
|
||||
return self.x == other.x and self.y == other.y and self.z == other.z
|
||||
|
||||
def __lt__(self, other: UVec) -> bool:
|
||||
"""Lower than operator.
|
||||
|
||||
Args:
|
||||
other: :class:`Vec3` compatible object
|
||||
|
||||
"""
|
||||
x, y, z = self.decompose(other)
|
||||
if self._x == x:
|
||||
if self._y == y:
|
||||
return self._z < z
|
||||
else:
|
||||
return self._y < y
|
||||
else:
|
||||
return self._x < x
|
||||
|
||||
def __add__(self, other: UVec) -> Vec3:
|
||||
"""Add :class:`Vec3` operator: `self` + `other`."""
|
||||
x, y, z = self.decompose(other)
|
||||
return self.__class__(self._x + x, self._y + y, self._z + z)
|
||||
|
||||
def __radd__(self, other: UVec) -> Vec3:
|
||||
"""RAdd :class:`Vec3` operator: `other` + `self`."""
|
||||
return self.__add__(other)
|
||||
|
||||
def __sub__(self, other: UVec) -> Vec3:
|
||||
"""Sub :class:`Vec3` operator: `self` - `other`."""
|
||||
|
||||
x, y, z = self.decompose(other)
|
||||
return self.__class__(self._x - x, self._y - y, self._z - z)
|
||||
|
||||
def __rsub__(self, other: UVec) -> Vec3:
|
||||
"""RSub :class:`Vec3` operator: `other` - `self`."""
|
||||
x, y, z = self.decompose(other)
|
||||
return self.__class__(x - self._x, y - self._y, z - self._z)
|
||||
|
||||
def __mul__(self, other: float) -> Vec3:
|
||||
"""Scalar Mul operator: `self` * `other`."""
|
||||
scalar = float(other)
|
||||
return self.__class__(self._x * scalar, self._y * scalar, self._z * scalar)
|
||||
|
||||
def __rmul__(self, other: float) -> Vec3:
|
||||
"""Scalar RMul operator: `other` * `self`."""
|
||||
return self.__mul__(other)
|
||||
|
||||
def __truediv__(self, other: float) -> Vec3:
|
||||
"""Scalar Div operator: `self` / `other`."""
|
||||
scalar = float(other)
|
||||
return self.__class__(self._x / scalar, self._y / scalar, self._z / scalar)
|
||||
|
||||
@staticmethod
|
||||
def sum(items: Iterable[UVec]) -> Vec3:
|
||||
"""Add all vectors in `items`."""
|
||||
s = NULLVEC
|
||||
for v in items:
|
||||
s += v
|
||||
return s
|
||||
|
||||
def dot(self, other: UVec) -> float:
|
||||
"""Dot operator: `self` . `other`
|
||||
|
||||
Args:
|
||||
other: :class:`Vec3` compatible object
|
||||
"""
|
||||
x, y, z = self.decompose(other)
|
||||
return self._x * x + self._y * y + self._z * z
|
||||
|
||||
def cross(self, other: UVec) -> Vec3:
|
||||
"""Cross operator: `self` x `other`
|
||||
|
||||
Args:
|
||||
other: :class:`Vec3` compatible object
|
||||
"""
|
||||
x, y, z = self.decompose(other)
|
||||
return self.__class__(
|
||||
self._y * z - self._z * y,
|
||||
self._z * x - self._x * z,
|
||||
self._x * y - self._y * x,
|
||||
)
|
||||
|
||||
def distance(self, other: UVec) -> float:
|
||||
"""Returns distance between `self` and `other` vector."""
|
||||
v = self.__class__(other)
|
||||
return v.__sub__(self).magnitude
|
||||
|
||||
def angle_between(self, other: UVec) -> float:
|
||||
"""Returns angle between `self` and `other` in radians. +angle is
|
||||
counter clockwise orientation.
|
||||
|
||||
Args:
|
||||
other: :class:`Vec3` compatible object
|
||||
|
||||
"""
|
||||
cos_theta = self.normalize().dot(self.__class__(other).normalize())
|
||||
# avoid domain errors caused by floating point imprecision:
|
||||
if cos_theta < -1.0:
|
||||
cos_theta = -1.0
|
||||
elif cos_theta > 1.0:
|
||||
cos_theta = 1.0
|
||||
return math.acos(cos_theta)
|
||||
|
||||
def angle_about(self, base: UVec, target: UVec) -> float:
|
||||
# (c) 2020 by Matt Broadway, MIT License
|
||||
"""Returns counter-clockwise angle in radians about `self` from `base` to
|
||||
`target` when projected onto the plane defined by `self` as the normal
|
||||
vector.
|
||||
|
||||
Args:
|
||||
base: base vector, defines angle 0
|
||||
target: target vector
|
||||
"""
|
||||
x_axis = (base - self.project(base)).normalize()
|
||||
y_axis = self.cross(x_axis).normalize()
|
||||
target_projected_x = x_axis.dot(target)
|
||||
target_projected_y = y_axis.dot(target)
|
||||
return math.atan2(target_projected_y, target_projected_x) % math.tau
|
||||
|
||||
def rotate(self, angle: float) -> Vec3:
|
||||
"""Returns vector rotated about `angle` around the z-axis.
|
||||
|
||||
Args:
|
||||
angle: angle in radians
|
||||
|
||||
"""
|
||||
v = self.__class__(self.x, self.y, 0.0)
|
||||
v = Vec3.from_angle(v.angle + angle, v.magnitude)
|
||||
return self.__class__(v.x, v.y, self.z)
|
||||
|
||||
def rotate_deg(self, angle: float) -> Vec3:
|
||||
"""Returns vector rotated about `angle` around the z-axis.
|
||||
|
||||
Args:
|
||||
angle: angle in degrees
|
||||
|
||||
"""
|
||||
return self.rotate(math.radians(angle))
|
||||
|
||||
|
||||
X_AXIS = Vec3(1, 0, 0)
|
||||
Y_AXIS = Vec3(0, 1, 0)
|
||||
Z_AXIS = Vec3(0, 0, 1)
|
||||
NULLVEC = Vec3(0, 0, 0)
|
||||
|
||||
|
||||
def distance(p1: UVec, p2: UVec) -> float:
|
||||
"""Returns distance between points `p1` and `p2`.
|
||||
|
||||
Args:
|
||||
p1: first point as :class:`Vec3` compatible object
|
||||
p2: second point as :class:`Vec3` compatible object
|
||||
|
||||
"""
|
||||
return Vec3(p1).distance(p2)
|
||||
|
||||
|
||||
def lerp(p1: UVec, p2: UVec, factor: float = 0.5) -> Vec3:
|
||||
"""Returns linear interpolation between points `p1` and `p2` as :class:`Vec3`.
|
||||
|
||||
Args:
|
||||
p1: first point as :class:`Vec3` compatible object
|
||||
p2: second point as :class:`Vec3` compatible object
|
||||
factor: interpolation factor (``0`` = `p1`, ``1`` = `p2`, ``0.5`` = mid point)
|
||||
|
||||
"""
|
||||
return Vec3(p1).lerp(p2, factor)
|
||||
|
||||
|
||||
class Vec2:
|
||||
"""Immutable 2D vector class.
|
||||
|
||||
Args:
|
||||
v: vector object with :attr:`x` and :attr:`y` attributes/properties or a
|
||||
sequence of float ``[x, y, ...]`` or x-axis as float if argument `y`
|
||||
is not ``None``
|
||||
y: second float for :code:`Vec2(x, y)`
|
||||
|
||||
:class:`Vec2` implements a subset of :class:`Vec3`.
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ["x", "y"]
|
||||
|
||||
def __init__(self, v=(0.0, 0.0), y=None) -> None:
|
||||
try: # fast path for Vec2() and Vec3() or any object providing x and y attributes
|
||||
self.x = v.x
|
||||
self.y = v.y
|
||||
except AttributeError:
|
||||
if y is None: # given one tuple
|
||||
self.x = float(v[0])
|
||||
self.y = float(v[1])
|
||||
else: # two floats given
|
||||
self.x = float(v)
|
||||
self.y = float(y)
|
||||
|
||||
@property
|
||||
def vec3(self) -> Vec3:
|
||||
"""Returns a 3D vector."""
|
||||
return Vec3(self.x, self.y, 0)
|
||||
|
||||
def round(self, ndigits=None) -> Vec2:
|
||||
"""Returns a new vector where all components are rounded to `ndigits`.
|
||||
|
||||
Uses standard Python :func:`round` function for rounding.
|
||||
"""
|
||||
return self.__class__(round(self.x, ndigits), round(self.y, ndigits))
|
||||
|
||||
@classmethod
|
||||
def list(cls, items: Iterable[UVec]) -> list[Vec2]:
|
||||
return list(cls.generate(items))
|
||||
|
||||
@classmethod
|
||||
def tuple(cls, items: Iterable[UVec]) -> Sequence[Vec2]:
|
||||
"""Returns a tuple of :class:`Vec3` objects."""
|
||||
return tuple(cls.generate(items))
|
||||
|
||||
@classmethod
|
||||
def generate(cls, items: Iterable[UVec]) -> Iterator[Vec2]:
|
||||
return (cls(item) for item in items)
|
||||
|
||||
@classmethod
|
||||
def from_angle(cls, angle: float, length: float = 1.0) -> Vec2:
|
||||
return cls(math.cos(angle) * length, math.sin(angle) * length)
|
||||
|
||||
@classmethod
|
||||
def from_deg_angle(cls, angle: float, length: float = 1.0) -> Vec2:
|
||||
return cls.from_angle(math.radians(angle), length)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "({0.x}, {0.y})".format(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "Vec2" + self.__str__()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return 2
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.x, self.y))
|
||||
|
||||
def copy(self) -> Vec2:
|
||||
return self.__class__((self.x, self.y))
|
||||
|
||||
__copy__ = copy
|
||||
|
||||
def __deepcopy__(self, memodict: dict) -> Vec2:
|
||||
try:
|
||||
return memodict[id(self)]
|
||||
except KeyError:
|
||||
v = self.copy()
|
||||
memodict[id(self)] = v
|
||||
return v
|
||||
|
||||
def __getitem__(self, index: int) -> float:
|
||||
if isinstance(index, slice):
|
||||
raise TypeError("slicing not supported")
|
||||
if index == 0:
|
||||
return self.x
|
||||
elif index == 1:
|
||||
return self.y
|
||||
else:
|
||||
raise IndexError(f"invalid index {index}")
|
||||
|
||||
def __iter__(self) -> Iterator[float]:
|
||||
yield self.x
|
||||
yield self.y
|
||||
|
||||
def __abs__(self) -> float:
|
||||
return self.magnitude
|
||||
|
||||
@property
|
||||
def magnitude(self) -> float:
|
||||
"""Returns length of vector."""
|
||||
return math.hypot(self.x, self.y)
|
||||
|
||||
@property
|
||||
def is_null(self) -> bool:
|
||||
return abs(self.x) <= ABS_TOL and abs(self.y) <= ABS_TOL
|
||||
|
||||
@property
|
||||
def angle(self) -> float:
|
||||
"""Angle of vector in radians."""
|
||||
return math.atan2(self.y, self.x)
|
||||
|
||||
@property
|
||||
def angle_deg(self) -> float:
|
||||
"""Angle of vector in degrees."""
|
||||
return math.degrees(self.angle)
|
||||
|
||||
def orthogonal(self, ccw: bool = True) -> Vec2:
|
||||
"""Orthogonal vector
|
||||
|
||||
Args:
|
||||
ccw: counter-clockwise if ``True`` else clockwise
|
||||
|
||||
"""
|
||||
if ccw:
|
||||
return self.__class__(-self.y, self.x)
|
||||
else:
|
||||
return self.__class__(self.y, -self.x)
|
||||
|
||||
def lerp(self, other: AnyVec, factor: float = 0.5) -> Vec2:
|
||||
"""Linear interpolation between `self` and `other`.
|
||||
|
||||
Args:
|
||||
other: target vector/point
|
||||
factor: interpolation factor (0=self, 1=other, 0.5=mid point)
|
||||
|
||||
Returns: interpolated vector
|
||||
|
||||
"""
|
||||
x = self.x + (other.x - self.x) * factor
|
||||
y = self.y + (other.y - self.y) * factor
|
||||
return self.__class__(x, y)
|
||||
|
||||
def project(self, other: AnyVec) -> Vec2:
|
||||
"""Project vector `other` onto `self`."""
|
||||
uv = self.normalize()
|
||||
return uv * uv.dot(other)
|
||||
|
||||
def normalize(self, length: float = 1.0) -> Vec2:
|
||||
return self.__mul__(length / self.magnitude)
|
||||
|
||||
def reversed(self) -> Vec2:
|
||||
return self.__class__(-self.x, -self.y)
|
||||
|
||||
__neg__ = reversed
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return not self.is_null
|
||||
|
||||
def isclose(
|
||||
self, other: AnyVec, *, rel_tol: float = 1e-9, abs_tol: float = 1e-12
|
||||
) -> bool:
|
||||
if not isinstance(other, Vec2):
|
||||
other = Vec2(other)
|
||||
return math.isclose(
|
||||
self.x, other.x, rel_tol=rel_tol, abs_tol=abs_tol
|
||||
) and math.isclose(self.y, other.y, rel_tol=rel_tol, abs_tol=abs_tol)
|
||||
|
||||
def __eq__(self, other: UVec) -> bool:
|
||||
if not isinstance(other, Vec2):
|
||||
other = Vec2(other)
|
||||
return self.x == other.x and self.y == other.y
|
||||
|
||||
def __lt__(self, other: UVec) -> bool:
|
||||
# accepts also tuples, for more convenience at testing
|
||||
x, y, *_ = other
|
||||
if self.x == x:
|
||||
return self.y < y
|
||||
else:
|
||||
return self.x < x
|
||||
|
||||
def __add__(self, other: AnyVec) -> Vec2:
|
||||
try:
|
||||
return self.__class__(self.x + other.x, self.y + other.y)
|
||||
except AttributeError:
|
||||
raise TypeError("invalid argument")
|
||||
|
||||
def __sub__(self, other: AnyVec) -> Vec2:
|
||||
try:
|
||||
return self.__class__(self.x - other.x, self.y - other.y)
|
||||
except AttributeError:
|
||||
raise TypeError("invalid argument")
|
||||
|
||||
def __rsub__(self, other: AnyVec) -> Vec2:
|
||||
try:
|
||||
return self.__class__(other.x - self.x, other.y - self.y)
|
||||
except AttributeError:
|
||||
raise TypeError("invalid argument")
|
||||
|
||||
def __mul__(self, other: float) -> Vec2:
|
||||
return self.__class__(self.x * other, self.y * other)
|
||||
|
||||
def __rmul__(self, other: float) -> Vec2:
|
||||
return self.__class__(self.x * other, self.y * other)
|
||||
|
||||
def __truediv__(self, other: float) -> Vec2:
|
||||
return self.__class__(self.x / other, self.y / other)
|
||||
|
||||
def dot(self, other: AnyVec) -> float:
|
||||
return self.x * other.x + self.y * other.y
|
||||
|
||||
def det(self, other: AnyVec) -> float:
|
||||
return self.x * other.y - self.y * other.x
|
||||
|
||||
def distance(self, other: AnyVec) -> float:
|
||||
return math.hypot(self.x - other.x, self.y - other.y)
|
||||
|
||||
def angle_between(self, other: AnyVec) -> float:
|
||||
"""Calculate angle between `self` and `other` in radians. +angle is
|
||||
counter-clockwise orientation.
|
||||
|
||||
"""
|
||||
cos_theta = self.normalize().dot(other.normalize())
|
||||
# avoid domain errors caused by floating point imprecision:
|
||||
if cos_theta < -1.0:
|
||||
cos_theta = -1.0
|
||||
elif cos_theta > 1.0:
|
||||
cos_theta = 1.0
|
||||
return math.acos(cos_theta)
|
||||
|
||||
def rotate(self, angle: float) -> Vec2:
|
||||
"""Rotate vector around origin.
|
||||
|
||||
Args:
|
||||
angle: angle in radians
|
||||
|
||||
"""
|
||||
return self.__class__.from_angle(self.angle + angle, self.magnitude)
|
||||
|
||||
def rotate_deg(self, angle: float) -> Vec2:
|
||||
"""Rotate vector around origin.
|
||||
|
||||
Args:
|
||||
angle: angle in degrees
|
||||
|
||||
Returns: rotated vector
|
||||
|
||||
"""
|
||||
return self.__class__.from_angle(
|
||||
self.angle + math.radians(angle), self.magnitude
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def sum(items: Iterable[Vec2]) -> Vec2:
|
||||
"""Add all vectors in `items`."""
|
||||
s = Vec2(0, 0)
|
||||
for v in items:
|
||||
s += v
|
||||
return s
|
||||
@@ -0,0 +1,539 @@
|
||||
# Copyright (c) 2018-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Iterable, Sequence, Iterator, Optional
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec2, UVec
|
||||
from .bbox import BoundingBox2d
|
||||
from .construct2d import enclosing_angles
|
||||
from .circle import ConstructionCircle
|
||||
from .line import ConstructionRay, ConstructionLine
|
||||
from .ucs import UCS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.entities import Arc
|
||||
from ezdxf.layouts import BaseLayout
|
||||
|
||||
__all__ = ["ConstructionArc", "arc_chord_length", "arc_segment_count"]
|
||||
|
||||
QUARTER_ANGLES = [0, math.pi * 0.5, math.pi, math.pi * 1.5]
|
||||
|
||||
|
||||
class ConstructionArc:
|
||||
"""Construction tool for 2D arcs.
|
||||
|
||||
:class:`ConstructionArc` represents a 2D arc in the xy-plane, use an
|
||||
:class:`UCS` to place a DXF :class:`~ezdxf.entities.Arc` entity in 3D space,
|
||||
see method :meth:`add_to_layout`.
|
||||
|
||||
Implements the 2D transformation tools: :meth:`translate`,
|
||||
:meth:`scale_uniform` and :meth:`rotate_z`
|
||||
|
||||
Args:
|
||||
center: center point as :class:`Vec2` compatible object
|
||||
radius: radius
|
||||
start_angle: start angle in degrees
|
||||
end_angle: end angle in degrees
|
||||
is_counter_clockwise: swaps start- and end angle if ``False``
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
center: UVec = (0, 0),
|
||||
radius: float = 1.0,
|
||||
start_angle: float = 0.0,
|
||||
end_angle: float = 360.0,
|
||||
is_counter_clockwise: Optional[bool] = True,
|
||||
):
|
||||
|
||||
self.center = Vec2(center)
|
||||
self.radius = radius
|
||||
if is_counter_clockwise:
|
||||
self.start_angle = start_angle
|
||||
self.end_angle = end_angle
|
||||
else:
|
||||
self.start_angle = end_angle
|
||||
self.end_angle = start_angle
|
||||
|
||||
@property
|
||||
def start_point(self) -> Vec2:
|
||||
"""start point of arc as :class:`Vec2`."""
|
||||
return self.center + Vec2.from_deg_angle(self.start_angle, self.radius)
|
||||
|
||||
@property
|
||||
def end_point(self) -> Vec2:
|
||||
"""end point of arc as :class:`Vec2`."""
|
||||
return self.center + Vec2.from_deg_angle(self.end_angle, self.radius)
|
||||
|
||||
@property
|
||||
def is_passing_zero(self) -> bool:
|
||||
"""Returns ``True`` if the arc passes the x-axis (== 0 degree)."""
|
||||
return self.start_angle > self.end_angle
|
||||
|
||||
@property
|
||||
def circle(self) -> ConstructionCircle:
|
||||
return ConstructionCircle(self.center, self.radius)
|
||||
|
||||
@property
|
||||
def bounding_box(self) -> BoundingBox2d:
|
||||
"""bounding box of arc as :class:`BoundingBox2d`."""
|
||||
bbox = BoundingBox2d((self.start_point, self.end_point))
|
||||
bbox.extend(self.main_axis_points())
|
||||
return bbox
|
||||
|
||||
def angles(self, num: int) -> Iterable[float]:
|
||||
"""Returns `num` angles from start- to end angle in degrees in
|
||||
counter-clockwise order.
|
||||
|
||||
All angles are normalized in the range from [0, 360).
|
||||
|
||||
"""
|
||||
if num < 2:
|
||||
raise ValueError("num >= 2")
|
||||
start: float = self.start_angle % 360
|
||||
stop: float = self.end_angle % 360
|
||||
if stop <= start:
|
||||
stop += 360
|
||||
for angle in np.linspace(start, stop, num=num, endpoint=True):
|
||||
yield angle % 360
|
||||
|
||||
@property
|
||||
def angle_span(self) -> float:
|
||||
"""Returns angle span of arc from start- to end param."""
|
||||
end: float = self.end_angle
|
||||
if end < self.start_angle:
|
||||
end += 360.0
|
||||
return end - self.start_angle
|
||||
|
||||
def vertices(self, a: Iterable[float]) -> Iterable[Vec2]:
|
||||
"""Yields vertices on arc for angles in iterable `a` in WCS as location
|
||||
vectors.
|
||||
|
||||
Args:
|
||||
a: angles in the range from 0 to 360 in degrees, arc goes
|
||||
counter clockwise around the z-axis, WCS x-axis = 0 deg.
|
||||
|
||||
"""
|
||||
center = self.center
|
||||
radius = self.radius
|
||||
|
||||
for angle in a:
|
||||
yield center + Vec2.from_deg_angle(angle, radius)
|
||||
|
||||
def flattening(self, sagitta: float) -> Iterable[Vec2]:
|
||||
"""Approximate the arc by vertices in WCS, argument `sagitta` is the
|
||||
max. distance from the center of an arc segment to the center of its
|
||||
chord.
|
||||
|
||||
"""
|
||||
radius: float = abs(self.radius)
|
||||
if radius > 0:
|
||||
start: float = self.start_angle
|
||||
stop: float = self.end_angle
|
||||
if math.isclose(start, stop):
|
||||
return
|
||||
start %= 360
|
||||
stop %= 360
|
||||
if stop <= start:
|
||||
stop += 360
|
||||
angle_span: float = math.radians(stop - start)
|
||||
count = arc_segment_count(radius, angle_span, sagitta)
|
||||
yield from self.vertices(np.linspace(start, stop, count + 1))
|
||||
|
||||
def tangents(self, a: Iterable[float]) -> Iterable[Vec2]:
|
||||
"""Yields tangents on arc for angles in iterable `a` in WCS as
|
||||
direction vectors.
|
||||
|
||||
Args:
|
||||
a: angles in the range from 0 to 360 in degrees, arc goes
|
||||
counter-clockwise around the z-axis, WCS x-axis = 0 deg.
|
||||
|
||||
"""
|
||||
for angle in a:
|
||||
r: float = math.radians(angle)
|
||||
yield Vec2((-math.sin(r), math.cos(r)))
|
||||
|
||||
def main_axis_points(self) -> Iterator[Vec2]:
|
||||
center: Vec2 = self.center
|
||||
radius: float = self.radius
|
||||
start: float = math.radians(self.start_angle)
|
||||
end: float = math.radians(self.end_angle)
|
||||
for angle in QUARTER_ANGLES:
|
||||
if enclosing_angles(angle, start, end):
|
||||
yield center + Vec2.from_angle(angle, radius)
|
||||
|
||||
def translate(self, dx: float, dy: float) -> ConstructionArc:
|
||||
"""Move arc about `dx` in x-axis and about `dy` in y-axis, returns
|
||||
`self` (floating interface).
|
||||
|
||||
Args:
|
||||
dx: translation in x-axis
|
||||
dy: translation in y-axis
|
||||
|
||||
"""
|
||||
self.center += Vec2(dx, dy)
|
||||
return self
|
||||
|
||||
def scale_uniform(self, s: float) -> ConstructionArc:
|
||||
"""Scale arc inplace uniform about `s` in x- and y-axis, returns
|
||||
`self` (floating interface).
|
||||
"""
|
||||
self.radius *= float(s)
|
||||
return self
|
||||
|
||||
def rotate_z(self, angle: float) -> ConstructionArc:
|
||||
"""Rotate arc inplace about z-axis, returns `self`
|
||||
(floating interface).
|
||||
|
||||
Args:
|
||||
angle: rotation angle in degrees
|
||||
|
||||
"""
|
||||
self.start_angle += angle
|
||||
self.end_angle += angle
|
||||
return self
|
||||
|
||||
@property
|
||||
def start_angle_rad(self) -> float:
|
||||
"""Returns the start angle in radians."""
|
||||
return math.radians(self.start_angle)
|
||||
|
||||
@property
|
||||
def end_angle_rad(self) -> float:
|
||||
"""Returns the end angle in radians."""
|
||||
return math.radians(self.end_angle)
|
||||
|
||||
@staticmethod
|
||||
def validate_start_and_end_point(
|
||||
start_point: UVec, end_point: UVec
|
||||
) -> tuple[Vec2, Vec2]:
|
||||
start_point = Vec2(start_point)
|
||||
end_point = Vec2(end_point)
|
||||
if start_point == end_point:
|
||||
raise ValueError("Start- and end point have to be different points.")
|
||||
return start_point, end_point
|
||||
|
||||
@classmethod
|
||||
def from_2p_angle(
|
||||
cls,
|
||||
start_point: UVec,
|
||||
end_point: UVec,
|
||||
angle: float,
|
||||
ccw: bool = True,
|
||||
) -> ConstructionArc:
|
||||
"""Create arc from two points and enclosing angle. Additional
|
||||
precondition: arc goes by default in counter-clockwise orientation from
|
||||
`start_point` to `end_point`, can be changed by `ccw` = ``False``.
|
||||
|
||||
Args:
|
||||
start_point: start point as :class:`Vec2` compatible object
|
||||
end_point: end point as :class:`Vec2` compatible object
|
||||
angle: enclosing angle in degrees
|
||||
ccw: counter-clockwise direction if ``True``
|
||||
|
||||
"""
|
||||
_start_point, _end_point = cls.validate_start_and_end_point(
|
||||
start_point, end_point
|
||||
)
|
||||
angle = math.radians(angle)
|
||||
if angle == 0:
|
||||
raise ValueError("Angle can not be 0.")
|
||||
if ccw is False:
|
||||
_start_point, _end_point = _end_point, _start_point
|
||||
alpha2: float = angle / 2.0
|
||||
distance: float = _end_point.distance(_start_point)
|
||||
distance2: float = distance / 2.0
|
||||
radius: float = distance2 / math.sin(alpha2)
|
||||
height: float = distance2 / math.tan(alpha2)
|
||||
mid_point: Vec2 = _end_point.lerp(_start_point, factor=0.5)
|
||||
|
||||
distance_vector: Vec2 = _end_point - _start_point
|
||||
height_vector: Vec2 = distance_vector.orthogonal().normalize(height)
|
||||
center: Vec2 = mid_point + height_vector
|
||||
|
||||
return ConstructionArc(
|
||||
center=center,
|
||||
radius=radius,
|
||||
start_angle=(_start_point - center).angle_deg,
|
||||
end_angle=(_end_point - center).angle_deg,
|
||||
is_counter_clockwise=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_2p_radius(
|
||||
cls,
|
||||
start_point: UVec,
|
||||
end_point: UVec,
|
||||
radius: float,
|
||||
ccw: bool = True,
|
||||
center_is_left: bool = True,
|
||||
) -> ConstructionArc:
|
||||
"""Create arc from two points and arc radius.
|
||||
Additional precondition: arc goes by default in counter-clockwise
|
||||
orientation from `start_point` to `end_point` can be changed
|
||||
by `ccw` = ``False``.
|
||||
|
||||
The parameter `center_is_left` defines if the center of the arc is
|
||||
left or right of the line from `start_point` to `end_point`.
|
||||
Parameter `ccw` = ``False`` swaps start- and end point, which also
|
||||
inverts the meaning of ``center_is_left``.
|
||||
|
||||
Args:
|
||||
start_point: start point as :class:`Vec2` compatible object
|
||||
end_point: end point as :class:`Vec2` compatible object
|
||||
radius: arc radius
|
||||
ccw: counter-clockwise direction if ``True``
|
||||
center_is_left: center point of arc is left of line from start- to
|
||||
end point if ``True``
|
||||
|
||||
"""
|
||||
_start_point, _end_point = cls.validate_start_and_end_point(
|
||||
start_point, end_point
|
||||
)
|
||||
radius = float(radius)
|
||||
if radius <= 0:
|
||||
raise ValueError("Radius has to be > 0.")
|
||||
if ccw is False:
|
||||
_start_point, _end_point = _end_point, _start_point
|
||||
|
||||
mid_point: Vec2 = _end_point.lerp(_start_point, factor=0.5)
|
||||
distance: float = _end_point.distance(_start_point)
|
||||
distance2: float = distance / 2.0
|
||||
height: float = math.sqrt(radius**2 - distance2**2)
|
||||
center: Vec2 = mid_point + (_end_point - _start_point).orthogonal(
|
||||
ccw=center_is_left
|
||||
).normalize(height)
|
||||
|
||||
return ConstructionArc(
|
||||
center=center,
|
||||
radius=radius,
|
||||
start_angle=(_start_point - center).angle_deg,
|
||||
end_angle=(_end_point - center).angle_deg,
|
||||
is_counter_clockwise=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_3p(
|
||||
cls,
|
||||
start_point: UVec,
|
||||
end_point: UVec,
|
||||
def_point: UVec,
|
||||
ccw: bool = True,
|
||||
) -> ConstructionArc:
|
||||
"""Create arc from three points.
|
||||
Additional precondition: arc goes in counter-clockwise
|
||||
orientation from `start_point` to `end_point`.
|
||||
|
||||
Args:
|
||||
start_point: start point as :class:`Vec2` compatible object
|
||||
end_point: end point as :class:`Vec2` compatible object
|
||||
def_point: additional definition point as :class:`Vec2` compatible
|
||||
object
|
||||
ccw: counter-clockwise direction if ``True``
|
||||
|
||||
"""
|
||||
start_point, end_point = cls.validate_start_and_end_point(
|
||||
start_point, end_point
|
||||
)
|
||||
def_point = Vec2(def_point)
|
||||
if def_point == start_point or def_point == end_point:
|
||||
raise ValueError("def_point has to be different to start- and end point")
|
||||
|
||||
circle = ConstructionCircle.from_3p(start_point, end_point, def_point)
|
||||
center = Vec2(circle.center)
|
||||
return ConstructionArc(
|
||||
center=center,
|
||||
radius=circle.radius,
|
||||
start_angle=(start_point - center).angle_deg,
|
||||
end_angle=(end_point - center).angle_deg,
|
||||
is_counter_clockwise=ccw,
|
||||
)
|
||||
|
||||
def add_to_layout(
|
||||
self, layout: BaseLayout, ucs: Optional[UCS] = None, dxfattribs=None
|
||||
) -> Arc:
|
||||
"""Add arc as DXF :class:`~ezdxf.entities.Arc` entity to a layout.
|
||||
|
||||
Supports 3D arcs by using an :ref:`UCS`. An :class:`ConstructionArc` is
|
||||
always defined in the xy-plane, but by using an arbitrary UCS, the arc
|
||||
can be placed in 3D space, automatically OCS transformation included.
|
||||
|
||||
Args:
|
||||
layout: destination layout as :class:`~ezdxf.layouts.BaseLayout`
|
||||
object
|
||||
ucs: place arc in 3D space by :class:`~ezdxf.math.UCS` object
|
||||
dxfattribs: additional DXF attributes for the ARC entity
|
||||
|
||||
"""
|
||||
arc = layout.add_arc(
|
||||
center=self.center,
|
||||
radius=self.radius,
|
||||
start_angle=self.start_angle,
|
||||
end_angle=self.end_angle,
|
||||
dxfattribs=dxfattribs,
|
||||
)
|
||||
return arc if ucs is None else arc.transform(ucs.matrix)
|
||||
|
||||
def intersect_ray(
|
||||
self, ray: ConstructionRay, abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of arc and `ray` as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
ray: intersection ray
|
||||
abs_tol: absolute tolerance for tests (e.g. test for tangents)
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 line intersects or touches the arc at one point
|
||||
2 line intersects the arc at two points
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(ray, ConstructionRay)
|
||||
return [
|
||||
point
|
||||
for point in self.circle.intersect_ray(ray, abs_tol)
|
||||
if self._is_point_in_arc_range(point)
|
||||
]
|
||||
|
||||
def intersect_line(
|
||||
self, line: ConstructionLine, abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of arc and `line` as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
line: intersection line
|
||||
abs_tol: absolute tolerance for tests (e.g. test for tangents)
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 line intersects or touches the arc at one point
|
||||
2 line intersects the arc at two points
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(line, ConstructionLine)
|
||||
return [
|
||||
point
|
||||
for point in self.circle.intersect_line(line, abs_tol)
|
||||
if self._is_point_in_arc_range(point)
|
||||
]
|
||||
|
||||
def intersect_circle(
|
||||
self, circle: ConstructionCircle, abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of arc and `circle` as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
circle: intersection circle
|
||||
abs_tol: absolute tolerance for tests
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 circle intersects or touches the arc at one point
|
||||
2 circle intersects the arc at two points
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(circle, ConstructionCircle)
|
||||
return [
|
||||
point
|
||||
for point in self.circle.intersect_circle(circle, abs_tol)
|
||||
if self._is_point_in_arc_range(point)
|
||||
]
|
||||
|
||||
def intersect_arc(
|
||||
self, other: ConstructionArc, abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of two arcs as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
other: other intersection arc
|
||||
abs_tol: absolute tolerance for tests
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 other arc intersects or touches the arc at one point
|
||||
2 other arc intersects the arc at two points
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(other, ConstructionArc)
|
||||
return [
|
||||
point
|
||||
for point in self.circle.intersect_circle(other.circle, abs_tol)
|
||||
if self._is_point_in_arc_range(point)
|
||||
and other._is_point_in_arc_range(point)
|
||||
]
|
||||
|
||||
def _is_point_in_arc_range(self, point: Vec2) -> bool:
|
||||
# The point has to be on the circle defined by the arc, this is not
|
||||
# tested here! Helper tools to check intersections.
|
||||
start: float = self.start_angle % 360.0
|
||||
end: float = self.end_angle % 360.0
|
||||
angle: float = (point - self.center).angle_deg % 360.0
|
||||
if start > end: # arc passes 0 degree
|
||||
return angle >= start or angle <= end
|
||||
return start <= angle <= end
|
||||
|
||||
|
||||
def arc_chord_length(radius: float, sagitta: float) -> float:
|
||||
"""Returns the chord length for an arc defined by `radius` and
|
||||
the `sagitta`_.
|
||||
|
||||
Args:
|
||||
radius: arc radius
|
||||
sagitta: distance from the center of the arc to the center of its base
|
||||
|
||||
"""
|
||||
try:
|
||||
return 2.0 * math.sqrt(2.0 * radius * sagitta - sagitta * sagitta)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def arc_segment_count(radius: float, angle: float, sagitta: float) -> int:
|
||||
"""Returns the count of required segments for the approximation
|
||||
of an arc for a given maximum `sagitta`_.
|
||||
|
||||
Args:
|
||||
radius: arc radius
|
||||
angle: angle span of the arc in radians
|
||||
sagitta: max. distance from the center of an arc segment to the
|
||||
center of its chord
|
||||
|
||||
"""
|
||||
try:
|
||||
chord_length: float = arc_chord_length(radius, sagitta)
|
||||
alpha: float = math.asin(chord_length / 2.0 / radius) * 2.0
|
||||
return math.ceil(angle / alpha)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return 1
|
||||
@@ -0,0 +1,457 @@
|
||||
# Copyright (c) 2019-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Optional, Iterator, Sequence, TypeVar, Generic
|
||||
import abc
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec3, Vec2, UVec
|
||||
|
||||
T = TypeVar("T", Vec2, Vec3)
|
||||
|
||||
__all__ = ["BoundingBox2d", "BoundingBox", "AbstractBoundingBox"]
|
||||
|
||||
|
||||
class AbstractBoundingBox(Generic[T], abc.ABC):
|
||||
extmin: T
|
||||
extmax: T
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, vertices: Optional[Iterable[UVec]] = None):
|
||||
...
|
||||
|
||||
def copy(self):
|
||||
box = self.__class__()
|
||||
box.extmin = self.extmin
|
||||
box.extmax = self.extmax
|
||||
return box
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.extmin}, {self.extmax}]"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name = self.__class__.__name__
|
||||
if self.has_data:
|
||||
return f"{name}({self.__str__()})"
|
||||
else:
|
||||
return f"{name}()"
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
if self.has_data:
|
||||
yield self.extmin
|
||||
yield self.extmax
|
||||
|
||||
@abc.abstractmethod
|
||||
def extend(self, vertices: Iterable[UVec]) -> None:
|
||||
...
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def is_empty(self) -> bool:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def inside(self, vertex: UVec) -> bool:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def has_intersection(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def has_overlap(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def intersection(self, other: AbstractBoundingBox[T]) -> AbstractBoundingBox[T]:
|
||||
...
|
||||
|
||||
def contains(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
"""Returns ``True`` if the `other` bounding box is completely inside
|
||||
this bounding box.
|
||||
|
||||
"""
|
||||
return self.inside(other.extmin) and self.inside(other.extmax)
|
||||
|
||||
def any_inside(self, vertices: Iterable[UVec]) -> bool:
|
||||
"""Returns ``True`` if any vertex is inside this bounding box.
|
||||
|
||||
Vertices at the box border are inside!
|
||||
"""
|
||||
if self.has_data:
|
||||
return any(self.inside(v) for v in vertices)
|
||||
return False
|
||||
|
||||
def all_inside(self, vertices: Iterable[UVec]) -> bool:
|
||||
"""Returns ``True`` if all vertices are inside this bounding box.
|
||||
|
||||
Vertices at the box border are inside!
|
||||
"""
|
||||
if self.has_data:
|
||||
# all() returns True for an empty set of vertices
|
||||
has_any = False
|
||||
for v in vertices:
|
||||
has_any = True
|
||||
if not self.inside(v):
|
||||
return False
|
||||
return has_any
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns ``True`` if the bonding box has known limits."""
|
||||
return math.isfinite(self.extmin.x)
|
||||
|
||||
@property
|
||||
def size(self) -> T:
|
||||
"""Returns size of bounding box."""
|
||||
return self.extmax - self.extmin
|
||||
|
||||
@property
|
||||
def center(self) -> T:
|
||||
"""Returns center of bounding box."""
|
||||
return self.extmin.lerp(self.extmax)
|
||||
|
||||
def union(self, other: AbstractBoundingBox[T]) -> AbstractBoundingBox[T]:
|
||||
"""Returns a new bounding box as union of this and `other` bounding
|
||||
box.
|
||||
"""
|
||||
vertices: list[T] = []
|
||||
if self.has_data:
|
||||
vertices.extend(self)
|
||||
if other.has_data:
|
||||
vertices.extend(other)
|
||||
return self.__class__(vertices)
|
||||
|
||||
def rect_vertices(self) -> Sequence[Vec2]:
|
||||
"""Returns the corners of the bounding box in the xy-plane as
|
||||
:class:`Vec2` objects.
|
||||
"""
|
||||
if self.has_data:
|
||||
x0, y0, *_ = self.extmin
|
||||
x1, y1, *_ = self.extmax
|
||||
return Vec2(x0, y0), Vec2(x1, y0), Vec2(x1, y1), Vec2(x0, y1)
|
||||
else:
|
||||
raise ValueError("empty bounding box")
|
||||
|
||||
def grow(self, value: float) -> None:
|
||||
"""Grow or shrink the bounding box by an uniform value in x, y and
|
||||
z-axis. A negative value shrinks the bounding box.
|
||||
Raises :class:`ValueError` for shrinking the size of the bounding box to
|
||||
zero or below in any dimension.
|
||||
"""
|
||||
if self.has_data:
|
||||
if value < 0.0:
|
||||
min_ext = min(self.size)
|
||||
if -value >= min_ext / 2.0:
|
||||
raise ValueError("shrinking one or more dimensions <= 0")
|
||||
self.extmax += Vec3(value, value, value)
|
||||
self.extmin += Vec3(-value, -value, -value)
|
||||
|
||||
|
||||
class BoundingBox(AbstractBoundingBox[Vec3]):
|
||||
"""3D bounding box.
|
||||
|
||||
Args:
|
||||
vertices: iterable of ``(x, y, z)`` tuples or :class:`Vec3` objects
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("extmin", "extmax")
|
||||
|
||||
def __init__(self, vertices: Optional[Iterable[UVec]] = None):
|
||||
self.extmin = Vec3(math.inf, math.inf, math.inf)
|
||||
self.extmax = self.extmin
|
||||
if vertices is not None:
|
||||
try:
|
||||
self.extmin, self.extmax = extents3d(vertices)
|
||||
except ValueError:
|
||||
# No or invalid data creates an empty BoundingBox
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Returns ``True`` if the bounding box is empty or the bounding box
|
||||
has a size of 0 in any or all dimensions or is undefined.
|
||||
|
||||
"""
|
||||
if self.has_data:
|
||||
sx, sy, sz = self.size
|
||||
return sx * sy * sz == 0.0
|
||||
return True
|
||||
|
||||
def extend(self, vertices: Iterable[UVec]) -> None:
|
||||
"""Extend bounds by `vertices`.
|
||||
|
||||
Args:
|
||||
vertices: iterable of vertices
|
||||
|
||||
"""
|
||||
v = list(vertices)
|
||||
if not v:
|
||||
return
|
||||
if self.has_data:
|
||||
v.extend([self.extmin, self.extmax])
|
||||
self.extmin, self.extmax = extents3d(v)
|
||||
|
||||
def inside(self, vertex: UVec) -> bool:
|
||||
"""Returns ``True`` if `vertex` is inside this bounding box.
|
||||
|
||||
Vertices at the box border are inside!
|
||||
"""
|
||||
if not self.has_data:
|
||||
return False
|
||||
x, y, z = Vec3(vertex).xyz
|
||||
xmin, ymin, zmin = self.extmin.xyz
|
||||
xmax, ymax, zmax = self.extmax.xyz
|
||||
return (xmin <= x <= xmax) and (ymin <= y <= ymax) and (zmin <= z <= zmax)
|
||||
|
||||
def has_intersection(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
"""Returns ``True`` if this bounding box intersects with `other` but does
|
||||
not include touching bounding boxes, see also :meth:`has_overlap`::
|
||||
|
||||
bbox1 = BoundingBox([(0, 0, 0), (1, 1, 1)])
|
||||
bbox2 = BoundingBox([(1, 1, 1), (2, 2, 2)])
|
||||
assert bbox1.has_intersection(bbox2) is False
|
||||
|
||||
"""
|
||||
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
||||
# Check for a separating axis:
|
||||
if not self.has_data or not other.has_data:
|
||||
return False
|
||||
o_min = Vec3(other.extmin) # could be a 2D bounding box
|
||||
o_max = Vec3(other.extmax) # could be a 2D bounding box
|
||||
|
||||
# Check for a separating axis:
|
||||
if self.extmin.x >= o_max.x:
|
||||
return False
|
||||
if self.extmax.x <= o_min.x:
|
||||
return False
|
||||
if self.extmin.y >= o_max.y:
|
||||
return False
|
||||
if self.extmax.y <= o_min.y:
|
||||
return False
|
||||
if self.extmin.z >= o_max.z:
|
||||
return False
|
||||
if self.extmax.z <= o_min.z:
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_overlap(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
"""Returns ``True`` if this bounding box intersects with `other` but
|
||||
in contrast to :meth:`has_intersection` includes touching bounding boxes too::
|
||||
|
||||
bbox1 = BoundingBox([(0, 0, 0), (1, 1, 1)])
|
||||
bbox2 = BoundingBox([(1, 1, 1), (2, 2, 2)])
|
||||
assert bbox1.has_overlap(bbox2) is True
|
||||
|
||||
"""
|
||||
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
||||
# Check for a separating axis:
|
||||
if not self.has_data or not other.has_data:
|
||||
return False
|
||||
o_min = Vec3(other.extmin) # could be a 2D bounding box
|
||||
o_max = Vec3(other.extmax) # could be a 2D bounding box
|
||||
# Check for a separating axis:
|
||||
if self.extmin.x > o_max.x:
|
||||
return False
|
||||
if self.extmax.x < o_min.x:
|
||||
return False
|
||||
if self.extmin.y > o_max.y:
|
||||
return False
|
||||
if self.extmax.y < o_min.y:
|
||||
return False
|
||||
if self.extmin.z > o_max.z:
|
||||
return False
|
||||
if self.extmax.z < o_min.z:
|
||||
return False
|
||||
return True
|
||||
|
||||
def cube_vertices(self) -> Sequence[Vec3]:
|
||||
"""Returns the 3D corners of the bounding box as :class:`Vec3` objects."""
|
||||
if self.has_data:
|
||||
x0, y0, z0 = self.extmin
|
||||
x1, y1, z1 = self.extmax
|
||||
return (
|
||||
Vec3(x0, y0, z0),
|
||||
Vec3(x1, y0, z0),
|
||||
Vec3(x1, y1, z0),
|
||||
Vec3(x0, y1, z0),
|
||||
Vec3(x0, y0, z1),
|
||||
Vec3(x1, y0, z1),
|
||||
Vec3(x1, y1, z1),
|
||||
Vec3(x0, y1, z1),
|
||||
)
|
||||
else:
|
||||
raise ValueError("empty bounding box")
|
||||
|
||||
def intersection(self, other: AbstractBoundingBox[T]) -> BoundingBox:
|
||||
"""Returns the bounding box of the intersection cube of both
|
||||
3D bounding boxes. Returns an empty bounding box if the intersection
|
||||
volume is 0.
|
||||
|
||||
"""
|
||||
new_bbox = self.__class__()
|
||||
if not self.has_intersection(other):
|
||||
return new_bbox
|
||||
s_min_x, s_min_y, s_min_z = Vec3(self.extmin)
|
||||
o_min_x, o_min_y, o_min_z = Vec3(other.extmin)
|
||||
s_max_x, s_max_y, s_max_z = Vec3(self.extmax)
|
||||
o_max_x, o_max_y, o_max_z = Vec3(other.extmax)
|
||||
new_bbox.extend(
|
||||
[
|
||||
(
|
||||
max(s_min_x, o_min_x),
|
||||
max(s_min_y, o_min_y),
|
||||
max(s_min_z, o_min_z),
|
||||
),
|
||||
(
|
||||
min(s_max_x, o_max_x),
|
||||
min(s_max_y, o_max_y),
|
||||
min(s_max_z, o_max_z),
|
||||
),
|
||||
]
|
||||
)
|
||||
return new_bbox
|
||||
|
||||
|
||||
class BoundingBox2d(AbstractBoundingBox[Vec2]):
|
||||
"""2D bounding box.
|
||||
|
||||
Args:
|
||||
vertices: iterable of ``(x, y[, z])`` tuples or :class:`Vec3` objects
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("extmin", "extmax")
|
||||
|
||||
def __init__(self, vertices: Optional[Iterable[UVec]] = None):
|
||||
self.extmin = Vec2(math.inf, math.inf)
|
||||
self.extmax = self.extmin
|
||||
if vertices is not None:
|
||||
try:
|
||||
self.extmin, self.extmax = extents2d(vertices)
|
||||
except ValueError:
|
||||
# No or invalid data creates an empty BoundingBox
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Returns ``True`` if the bounding box is empty. The bounding box has a
|
||||
size of 0 in any or all dimensions or is undefined.
|
||||
"""
|
||||
if self.has_data:
|
||||
sx, sy = self.size
|
||||
return sx * sy == 0.0
|
||||
return True
|
||||
|
||||
def extend(self, vertices: Iterable[UVec]) -> None:
|
||||
"""Extend bounds by `vertices`.
|
||||
|
||||
Args:
|
||||
vertices: iterable of vertices
|
||||
|
||||
"""
|
||||
v = list(vertices)
|
||||
if not v:
|
||||
return
|
||||
if self.has_data:
|
||||
v.extend([self.extmin, self.extmax])
|
||||
self.extmin, self.extmax = extents2d(v)
|
||||
|
||||
def inside(self, vertex: UVec) -> bool:
|
||||
"""Returns ``True`` if `vertex` is inside this bounding box.
|
||||
|
||||
Vertices at the box border are inside!
|
||||
"""
|
||||
if not self.has_data:
|
||||
return False
|
||||
v = Vec2(vertex)
|
||||
min_ = self.extmin
|
||||
max_ = self.extmax
|
||||
return (min_.x <= v.x <= max_.x) and (min_.y <= v.y <= max_.y)
|
||||
|
||||
def has_intersection(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
"""Returns ``True`` if this bounding box intersects with `other` but does
|
||||
not include touching bounding boxes, see also :meth:`has_overlap`::
|
||||
|
||||
bbox1 = BoundingBox2d([(0, 0), (1, 1)])
|
||||
bbox2 = BoundingBox2d([(1, 1), (2, 2)])
|
||||
assert bbox1.has_intersection(bbox2) is False
|
||||
|
||||
"""
|
||||
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
||||
if not self.has_data or not other.has_data:
|
||||
return False
|
||||
# Check for a separating axis:
|
||||
if self.extmin.x >= other.extmax.x:
|
||||
return False
|
||||
if self.extmax.x <= other.extmin.x:
|
||||
return False
|
||||
if self.extmin.y >= other.extmax.y:
|
||||
return False
|
||||
if self.extmax.y <= other.extmin.y:
|
||||
return False
|
||||
return True
|
||||
|
||||
def intersection(self, other: AbstractBoundingBox[T]) -> BoundingBox2d:
|
||||
"""Returns the bounding box of the intersection rectangle of both
|
||||
2D bounding boxes. Returns an empty bounding box if the intersection
|
||||
area is 0.
|
||||
"""
|
||||
new_bbox = self.__class__()
|
||||
if not self.has_intersection(other):
|
||||
return new_bbox
|
||||
s_min_x, s_min_y = Vec2(self.extmin)
|
||||
o_min_x, o_min_y = Vec2(other.extmin)
|
||||
s_max_x, s_max_y = Vec2(self.extmax)
|
||||
o_max_x, o_max_y = Vec2(other.extmax)
|
||||
new_bbox.extend(
|
||||
[
|
||||
(max(s_min_x, o_min_x), max(s_min_y, o_min_y)),
|
||||
(min(s_max_x, o_max_x), min(s_max_y, o_max_y)),
|
||||
]
|
||||
)
|
||||
return new_bbox
|
||||
|
||||
def has_overlap(self, other: AbstractBoundingBox[T]) -> bool:
|
||||
"""Returns ``True`` if this bounding box intersects with `other` but
|
||||
in contrast to :meth:`has_intersection` includes touching bounding boxes too::
|
||||
|
||||
bbox1 = BoundingBox2d([(0, 0), (1, 1)])
|
||||
bbox2 = BoundingBox2d([(1, 1), (2, 2)])
|
||||
assert bbox1.has_overlap(bbox2) is True
|
||||
|
||||
"""
|
||||
# Source: https://gamemath.com/book/geomtests.html#intersection_two_aabbs
|
||||
if not self.has_data or not other.has_data:
|
||||
return False
|
||||
# Check for a separating axis:
|
||||
if self.extmin.x > other.extmax.x:
|
||||
return False
|
||||
if self.extmax.x < other.extmin.x:
|
||||
return False
|
||||
if self.extmin.y > other.extmax.y:
|
||||
return False
|
||||
if self.extmax.y < other.extmin.y:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def extents3d(vertices: Iterable[UVec]) -> tuple[Vec3, Vec3]:
|
||||
"""Returns the extents of the bounding box as tuple (extmin, extmax)."""
|
||||
vertices = np.array([Vec3(v).xyz for v in vertices], dtype=np.float64)
|
||||
if len(vertices):
|
||||
return Vec3(vertices.min(0)), Vec3(vertices.max(0))
|
||||
else:
|
||||
raise ValueError("no vertices given")
|
||||
|
||||
|
||||
def extents2d(vertices: Iterable[UVec]) -> tuple[Vec2, Vec2]:
|
||||
"""Returns the extents of the bounding box as tuple (extmin, extmax)."""
|
||||
vertices = np.array([(x, y) for x, y, *_ in vertices], dtype=np.float64)
|
||||
if len(vertices):
|
||||
return Vec2(vertices.min(0)), Vec2(vertices.max(0))
|
||||
else:
|
||||
raise ValueError("no vertices given")
|
||||
@@ -0,0 +1,260 @@
|
||||
# Copyright (c) 2010-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence
|
||||
from functools import lru_cache
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec3, NULLVEC, Matrix44, UVec
|
||||
|
||||
|
||||
__all__ = ["Bezier"]
|
||||
|
||||
|
||||
"""
|
||||
|
||||
Bezier curves
|
||||
=============
|
||||
|
||||
https://www.cl.cam.ac.uk/teaching/2000/AGraphHCI/SMEG/node3.html
|
||||
|
||||
A Bezier curve is a weighted sum of n+1 control points, P0, P1, ..., Pn, where
|
||||
the weights are the Bernstein polynomials.
|
||||
|
||||
The Bezier curve of order n+1 (degree n) has n+1 control points. These are the
|
||||
first three orders of Bezier curve definitions.
|
||||
|
||||
(75) linear P(t) = (1-t)*P0 + t*P1
|
||||
(76) quadratic P(t) = (1-t)^2*P0 + 2*(t-1)*t*P1 + t^2*P2
|
||||
(77) cubic P(t) = (1-t)^3*P0 + 3*(1-t)^2*t*P1 + 3*(1-t)*t^2*P2 + t^3*P3
|
||||
|
||||
Ways of thinking about Bezier curves
|
||||
------------------------------------
|
||||
|
||||
There are several useful ways in which you can think about Bezier curves.
|
||||
Here are the ones that I use.
|
||||
|
||||
Linear interpolation
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Equation (75) is obviously a linear interpolation between two points. Equation
|
||||
(76) can be rewritten as a linear interpolation between linear interpolations
|
||||
between points.
|
||||
|
||||
Weighted average
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
A Bezier curve can be seen as a weighted average of all of its control points.
|
||||
Because all of the weights are positive, and because the weights sum to one, the
|
||||
Bezier curve is guaranteed to lie within the convex hull of its control points.
|
||||
|
||||
Refinement of the control polygon
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A Bezier curve can be seen as some sort of refinement of the polygon made by
|
||||
connecting its control points in order. The Bezier curve starts and ends at the
|
||||
two end points and its shape is determined by the relative positions of the n-1
|
||||
other control points, although it will generally not pass through these other
|
||||
control points. The tangent vectors at the start and end of the curve pass
|
||||
through the end point and the immediately adjacent point.
|
||||
|
||||
Continuity
|
||||
----------
|
||||
|
||||
You should note that each Bezier curve is independent of any other Bezier curve.
|
||||
If we wish two Bezier curves to join with any type of continuity, then we must
|
||||
explicitly position the control points of the second curve so that they bear
|
||||
the appropriate relationship with the control points in the first curve.
|
||||
|
||||
Any Bezier curve is infinitely differentiable within itself, and is therefore
|
||||
continuous to any degree.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class Bezier:
|
||||
"""Generic `Bézier curve`_ of any degree.
|
||||
|
||||
A `Bézier curve`_ is a parametric curve used in computer graphics and
|
||||
related fields. Bézier curves are used to model smooth curves that can be
|
||||
scaled indefinitely. "Paths", as they are commonly referred to in image
|
||||
manipulation programs, are combinations of linked Bézier curves.
|
||||
Paths are not bound by the limits of rasterized images and are intuitive to
|
||||
modify. (Source: Wikipedia)
|
||||
|
||||
This is a generic implementation which works with any count of definition
|
||||
points greater than 2, but it is a simple and slow implementation. For more
|
||||
performance look at the specialized :class:`Bezier4P` and :class:`Bezier3P`
|
||||
classes.
|
||||
|
||||
Objects are immutable.
|
||||
|
||||
Args:
|
||||
defpoints: iterable of definition points as :class:`Vec3` compatible objects.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, defpoints: Iterable[UVec]):
|
||||
self._defpoints: Sequence[Vec3] = Vec3.tuple(defpoints)
|
||||
|
||||
@property
|
||||
def control_points(self) -> Sequence[Vec3]:
|
||||
"""Control points as tuple of :class:`Vec3` objects."""
|
||||
return self._defpoints
|
||||
|
||||
def approximate(self, segments: int = 20) -> Iterable[Vec3]:
|
||||
"""Approximates curve by vertices as :class:`Vec3` objects, vertices
|
||||
count = segments + 1.
|
||||
"""
|
||||
return self.points(self.params(segments))
|
||||
|
||||
def flattening(self, distance: float, segments: int = 4) -> Iterable[Vec3]:
|
||||
"""Adaptive recursive flattening. The argument `segments` is the
|
||||
minimum count of approximation segments, if the distance from the center
|
||||
of the approximation segment to the curve is bigger than `distance` the
|
||||
segment will be subdivided.
|
||||
|
||||
Args:
|
||||
distance: maximum distance from the center of the curve (Cn)
|
||||
to the center of the linear (C1) curve between two
|
||||
approximation points to determine if a segment should be
|
||||
subdivided.
|
||||
segments: minimum segment count
|
||||
|
||||
"""
|
||||
|
||||
def subdiv(start_point, end_point, start_t: float, end_t: float):
|
||||
mid_t = (start_t + end_t) * 0.5
|
||||
mid_point = self.point(mid_t)
|
||||
chk_point = start_point.lerp(end_point)
|
||||
# center point point is faster than projecting mid point onto
|
||||
# vector start -> end:
|
||||
if chk_point.distance(mid_point) < distance:
|
||||
yield end_point
|
||||
else:
|
||||
yield from subdiv(start_point, mid_point, start_t, mid_t)
|
||||
yield from subdiv(mid_point, end_point, mid_t, end_t)
|
||||
|
||||
dt = 1.0 / segments
|
||||
t0 = 0.0
|
||||
start_point = self._defpoints[0]
|
||||
yield start_point
|
||||
while t0 < 1.0:
|
||||
t1 = t0 + dt
|
||||
if math.isclose(t1, 1.0):
|
||||
end_point = self._defpoints[-1]
|
||||
t1 = 1.0
|
||||
else:
|
||||
end_point = self.point(t1)
|
||||
yield from subdiv(start_point, end_point, t0, t1)
|
||||
t0 = t1
|
||||
start_point = end_point
|
||||
|
||||
def params(self, segments: int) -> Iterable[float]:
|
||||
"""Yield evenly spaced parameters from 0 to 1 for given segment count."""
|
||||
yield from np.linspace(0.0, 1.0, segments + 1)
|
||||
|
||||
def point(self, t: float) -> Vec3:
|
||||
"""Returns a point for parameter `t` in range [0, 1] as :class:`Vec3`
|
||||
object.
|
||||
"""
|
||||
if t < 0.0 or t > 1.0:
|
||||
raise ValueError("Parameter t not in range [0, 1]")
|
||||
if (1.0 - t) < 5e-6:
|
||||
t = 1.0
|
||||
point = NULLVEC
|
||||
pts = self._defpoints
|
||||
n = len(pts)
|
||||
|
||||
for i in range(n):
|
||||
point += bernstein_basis(n - 1, i, t) * pts[i]
|
||||
return point
|
||||
|
||||
def points(self, t: Iterable[float]) -> Iterable[Vec3]:
|
||||
"""Yields multiple points for parameters in vector `t` as :class:`Vec3`
|
||||
objects. Parameters have to be in range [0, 1].
|
||||
"""
|
||||
for u in t:
|
||||
yield self.point(u)
|
||||
|
||||
def derivative(self, t: float) -> tuple[Vec3, Vec3, Vec3]:
|
||||
"""Returns (point, 1st derivative, 2nd derivative) tuple for parameter `t`
|
||||
in range [0, 1] as :class:`Vec3` objects.
|
||||
"""
|
||||
if t < 0.0 or t > 1.0:
|
||||
raise ValueError("Parameter t not in range [0, 1]")
|
||||
|
||||
if (1.0 - t) < 5e-6:
|
||||
t = 1.0
|
||||
pts = self._defpoints
|
||||
n = len(pts)
|
||||
n0 = n - 1
|
||||
point = NULLVEC
|
||||
d1 = NULLVEC
|
||||
d2 = NULLVEC
|
||||
t2 = t * t
|
||||
n0_1 = n0 - 1
|
||||
if t == 0.0:
|
||||
d1 = n0 * (pts[1] - pts[0])
|
||||
d2 = n0 * n0_1 * (pts[0] - 2.0 * pts[1] + pts[2])
|
||||
for i in range(n):
|
||||
tmp_bas = bernstein_basis(n0, i, t)
|
||||
point += tmp_bas * pts[i]
|
||||
if 0.0 < t < 1.0:
|
||||
_1_t = 1.0 - t
|
||||
i_n0_t = i - n0 * t
|
||||
d1 += i_n0_t / (t * _1_t) * tmp_bas * pts[i]
|
||||
d2 += (
|
||||
(i_n0_t * i_n0_t - n0 * t2 - i * (1.0 - 2.0 * t))
|
||||
/ (t2 * _1_t * _1_t)
|
||||
* tmp_bas
|
||||
* pts[i]
|
||||
)
|
||||
if t == 1.0:
|
||||
d1 = n0 * (pts[n0] - pts[n0_1])
|
||||
d2 = n0 * n0_1 * (pts[n0] - 2 * pts[n0_1] + pts[n0 - 2])
|
||||
return point, d1, d2
|
||||
|
||||
def derivatives(
|
||||
self, t: Iterable[float]
|
||||
) -> Iterable[tuple[Vec3, Vec3, Vec3]]:
|
||||
"""Returns multiple (point, 1st derivative, 2nd derivative) tuples for
|
||||
parameter vector `t` as :class:`Vec3` objects.
|
||||
Parameters in range [0, 1]
|
||||
"""
|
||||
for u in t:
|
||||
yield self.derivative(u)
|
||||
|
||||
def reverse(self) -> Bezier:
|
||||
"""Returns a new Bèzier-curve with reversed control point order."""
|
||||
return Bezier(list(reversed(self.control_points)))
|
||||
|
||||
def transform(self, m: Matrix44) -> Bezier:
|
||||
"""General transformation interface, returns a new :class:`Bezier` curve.
|
||||
|
||||
Args:
|
||||
m: 4x4 transformation matrix (:class:`ezdxf.math.Matrix44`)
|
||||
|
||||
"""
|
||||
defpoints = tuple(m.transform_vertices(self.control_points))
|
||||
return Bezier(defpoints)
|
||||
|
||||
|
||||
def bernstein_basis(n: int, i: int, t: float) -> float:
|
||||
# handle the special cases to avoid domain problem with pow
|
||||
if t == 0.0 and i == 0:
|
||||
ti = 1.0
|
||||
else:
|
||||
ti = pow(t, i)
|
||||
if n == i and t == 1.0:
|
||||
tni = 1.0
|
||||
else:
|
||||
tni = pow((1.0 - t), (n - i))
|
||||
Ni = factorial(n) / (factorial(i) * factorial(n - i))
|
||||
return Ni * ti * tni
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def factorial(n: int):
|
||||
return math.factorial(n)
|
||||
@@ -0,0 +1,76 @@
|
||||
# Copyright (c) 2010-2023 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence
|
||||
from ezdxf.math import Vec3, Bezier4P, UVec
|
||||
|
||||
# These are low-level interpolation tools for B-splines, which can not be
|
||||
# integrated into other curve related modules!
|
||||
__all__ = ["cubic_bezier_interpolation", "tangents_cubic_bezier_interpolation"]
|
||||
|
||||
|
||||
def cubic_bezier_interpolation(
|
||||
points: Iterable[UVec],
|
||||
) -> Iterable[Bezier4P[Vec3]]:
|
||||
"""Returns an interpolation curve for given data `points` as multiple cubic
|
||||
Bézier-curves. Returns n-1 cubic Bézier-curves for n given data points,
|
||||
curve i goes from point[i] to point[i+1].
|
||||
|
||||
Args:
|
||||
points: data points
|
||||
|
||||
"""
|
||||
from ezdxf.math.linalg import tridiagonal_matrix_solver
|
||||
|
||||
# Source: https://towardsdatascience.com/b%C3%A9zier-interpolation-8033e9a262c2
|
||||
pnts = Vec3.tuple(points)
|
||||
if len(pnts) < 3:
|
||||
return
|
||||
|
||||
num = len(pnts) - 1
|
||||
|
||||
# setup tri-diagonal matrix (a, b, c)
|
||||
b = [4.0] * num
|
||||
a = [1.0] * num
|
||||
c = [1.0] * num
|
||||
b[0] = 2.0
|
||||
b[num - 1] = 7.0
|
||||
a[num - 1] = 2.0
|
||||
|
||||
# setup right-hand side quantities
|
||||
points_vector = [pnts[0] + 2.0 * pnts[1]]
|
||||
points_vector.extend(
|
||||
2.0 * (2.0 * pnts[i] + pnts[i + 1]) for i in range(1, num - 1)
|
||||
)
|
||||
points_vector.append(8.0 * pnts[num - 1] + pnts[num])
|
||||
|
||||
# solve tri-diagonal linear equation system
|
||||
solution = tridiagonal_matrix_solver([a, b, c], points_vector)
|
||||
control_points_1 = Vec3.list(solution.rows())
|
||||
control_points_2 = [
|
||||
p * 2.0 - cp for p, cp in zip(pnts[1:], control_points_1[1:])
|
||||
]
|
||||
control_points_2.append((control_points_1[num - 1] + pnts[num]) / 2.0)
|
||||
|
||||
for defpoints in zip(
|
||||
pnts, control_points_1, control_points_2, pnts[1:]
|
||||
):
|
||||
yield Bezier4P(defpoints)
|
||||
|
||||
|
||||
def tangents_cubic_bezier_interpolation(
|
||||
fit_points: Sequence[Vec3], normalize=True
|
||||
) -> list[Vec3]:
|
||||
if len(fit_points) < 3:
|
||||
raise ValueError("At least 3 points required")
|
||||
|
||||
curves = list(cubic_bezier_interpolation(fit_points))
|
||||
tangents = [
|
||||
(curve.control_points[1] - curve.control_points[0]) for curve in curves
|
||||
]
|
||||
|
||||
last_points = curves[-1].control_points
|
||||
tangents.append(last_points[3] - last_points[2])
|
||||
if normalize:
|
||||
tangents = [t.normalize() for t in tangents]
|
||||
return tangents
|
||||
@@ -0,0 +1,268 @@
|
||||
# Copyright (c) 2019-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Iterable
|
||||
import math
|
||||
from ezdxf.math import Vec2, UVec
|
||||
from .bbox import BoundingBox2d
|
||||
from .line import ConstructionLine
|
||||
from .construct2d import point_to_line_relation
|
||||
|
||||
|
||||
ABS_TOL = 1e-12
|
||||
|
||||
__all__ = ["ConstructionBox"]
|
||||
|
||||
|
||||
class ConstructionBox:
|
||||
"""Construction tool for 2D rectangles.
|
||||
|
||||
Args:
|
||||
center: center of rectangle
|
||||
width: width of rectangle
|
||||
height: height of rectangle
|
||||
angle: angle of rectangle in degrees
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
center: UVec = (0, 0),
|
||||
width: float = 1,
|
||||
height: float = 1,
|
||||
angle: float = 0,
|
||||
):
|
||||
self._center = Vec2(center)
|
||||
self._width: float = abs(width)
|
||||
self._height: float = abs(height)
|
||||
self._angle: float = angle # in degrees
|
||||
# corners: lower left, lower right, upper right, upper left:
|
||||
self._corners: Sequence[Vec2] = tuple()
|
||||
self._tainted: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_points(cls, p1: UVec, p2: UVec) -> ConstructionBox:
|
||||
"""Creates a box from two opposite corners, box sides are parallel to x-
|
||||
and y-axis.
|
||||
|
||||
Args:
|
||||
p1: first corner as :class:`Vec2` compatible object
|
||||
p2: second corner as :class:`Vec2` compatible object
|
||||
|
||||
"""
|
||||
_p1 = Vec2(p1)
|
||||
_p2 = Vec2(p2)
|
||||
width: float = abs(_p2.x - _p1.x)
|
||||
height: float = abs(_p2.y - _p1.y)
|
||||
center: Vec2 = _p1.lerp(_p2)
|
||||
return cls(center=center, width=width, height=height)
|
||||
|
||||
def update(self) -> None:
|
||||
if not self._tainted:
|
||||
return
|
||||
center = self.center
|
||||
w2 = Vec2.from_deg_angle(self._angle, self._width / 2.0)
|
||||
h2 = Vec2.from_deg_angle(self._angle + 90, self._height / 2.0)
|
||||
self._corners = (
|
||||
center - w2 - h2, # lower left
|
||||
center + w2 - h2, # lower right
|
||||
center + w2 + h2, # upper right
|
||||
center - w2 + h2, # upper left
|
||||
)
|
||||
self._tainted = False
|
||||
|
||||
@property
|
||||
def bounding_box(self) -> BoundingBox2d:
|
||||
""":class:`BoundingBox2d`"""
|
||||
return BoundingBox2d(self.corners)
|
||||
|
||||
@property
|
||||
def center(self) -> Vec2:
|
||||
"""box center"""
|
||||
return self._center
|
||||
|
||||
@center.setter
|
||||
def center(self, c: UVec) -> None:
|
||||
self._center = Vec2(c)
|
||||
self._tainted = True
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
"""box width"""
|
||||
return self._width
|
||||
|
||||
@width.setter
|
||||
def width(self, w: float) -> None:
|
||||
self._width = abs(w)
|
||||
self._tainted = True
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
"""box height"""
|
||||
return self._height
|
||||
|
||||
@height.setter
|
||||
def height(self, h: float) -> None:
|
||||
self._height = abs(h)
|
||||
self._tainted = True
|
||||
|
||||
@property
|
||||
def incircle_radius(self) -> float:
|
||||
"""incircle radius"""
|
||||
return min(self._width, self._height) * 0.5
|
||||
|
||||
@property
|
||||
def circumcircle_radius(self) -> float:
|
||||
"""circum circle radius"""
|
||||
return math.hypot(self._width, self._height) * 0.5
|
||||
|
||||
@property
|
||||
def angle(self) -> float:
|
||||
"""rotation angle in degrees"""
|
||||
return self._angle
|
||||
|
||||
@angle.setter
|
||||
def angle(self, a: float) -> None:
|
||||
self._angle = a
|
||||
self._tainted = True
|
||||
|
||||
@property
|
||||
def corners(self) -> Sequence[Vec2]:
|
||||
"""box corners as sequence of :class:`Vec2` objects."""
|
||||
self.update()
|
||||
return self._corners
|
||||
|
||||
def __iter__(self) -> Iterable[Vec2]:
|
||||
"""Iterable of box corners as :class:`Vec2` objects."""
|
||||
return iter(self.corners)
|
||||
|
||||
def __getitem__(self, corner) -> Vec2:
|
||||
"""Get corner by index `corner`, ``list`` like slicing is supported."""
|
||||
return self.corners[corner]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Returns string representation of box as
|
||||
``ConstructionBox(center, width, height, angle)``"""
|
||||
return f"ConstructionBox({self.center}, {self.width}, {self.height}, {self.angle})"
|
||||
|
||||
def translate(self, dx: float, dy: float) -> None:
|
||||
"""Move box about `dx` in x-axis and about `dy` in y-axis.
|
||||
|
||||
Args:
|
||||
dx: translation in x-axis
|
||||
dy: translation in y-axis
|
||||
|
||||
"""
|
||||
self.center += Vec2((dx, dy))
|
||||
|
||||
def expand(self, dw: float, dh: float) -> None:
|
||||
"""Expand box: `dw` expand width, `dh` expand height."""
|
||||
self.width += dw
|
||||
self.height += dh
|
||||
|
||||
def scale(self, sw: float, sh: float) -> None:
|
||||
"""Scale box: `sw` scales width, `sh` scales height."""
|
||||
self.width *= sw
|
||||
self.height *= sh
|
||||
|
||||
def rotate(self, angle: float) -> None:
|
||||
"""Rotate box by `angle` in degrees."""
|
||||
self.angle += angle
|
||||
|
||||
def is_inside(self, point: UVec) -> bool:
|
||||
"""Returns ``True`` if `point` is inside of box."""
|
||||
point = Vec2(point)
|
||||
delta = self.center - point
|
||||
if abs(self.angle) < ABS_TOL: # fast path for horizontal rectangles
|
||||
return abs(delta.x) <= (self._width / 2.0) and abs(delta.y) <= (
|
||||
self._height / 2.0
|
||||
)
|
||||
else:
|
||||
distance = delta.magnitude
|
||||
if distance > self.circumcircle_radius:
|
||||
return False
|
||||
elif distance <= self.incircle_radius:
|
||||
return True
|
||||
else:
|
||||
# inside if point is "left of line" of all borderlines.
|
||||
p1, p2, p3, p4 = self.corners
|
||||
return all(
|
||||
(
|
||||
point_to_line_relation(point, a, b) < 1
|
||||
for a, b in [(p1, p2), (p2, p3), (p3, p4), (p4, p1)]
|
||||
)
|
||||
)
|
||||
|
||||
def is_any_corner_inside(self, other: ConstructionBox) -> bool:
|
||||
"""Returns ``True`` if any corner of `other` box is inside this box."""
|
||||
return any(self.is_inside(p) for p in other.corners)
|
||||
|
||||
def is_overlapping(self, other: ConstructionBox) -> bool:
|
||||
"""Returns ``True`` if this box and `other` box do overlap."""
|
||||
distance = (self.center - other.center).magnitude
|
||||
max_distance = self.circumcircle_radius + other.circumcircle_radius
|
||||
if distance > max_distance:
|
||||
return False
|
||||
min_distance = self.incircle_radius + other.incircle_radius
|
||||
if distance <= min_distance:
|
||||
return True
|
||||
|
||||
if self.is_any_corner_inside(other):
|
||||
return True
|
||||
if other.is_any_corner_inside(self):
|
||||
return True
|
||||
# no corner is inside of any box, maybe crossing boxes?
|
||||
# check intersection of diagonals
|
||||
c1, c2, c3, c4 = self.corners
|
||||
diag1 = ConstructionLine(c1, c3)
|
||||
diag2 = ConstructionLine(c2, c4)
|
||||
|
||||
t1, t2, t3, t4 = other.corners
|
||||
test_diag = ConstructionLine(t1, t3)
|
||||
if test_diag.has_intersection(diag1) or test_diag.has_intersection(
|
||||
diag2
|
||||
):
|
||||
return True
|
||||
test_diag = ConstructionLine(t2, t4)
|
||||
if test_diag.has_intersection(diag1) or test_diag.has_intersection(
|
||||
diag2
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def border_lines(self) -> Sequence[ConstructionLine]:
|
||||
"""Returns borderlines of box as sequence of :class:`ConstructionLine`."""
|
||||
p1, p2, p3, p4 = self.corners
|
||||
return (
|
||||
ConstructionLine(p1, p2),
|
||||
ConstructionLine(p2, p3),
|
||||
ConstructionLine(p3, p4),
|
||||
ConstructionLine(p4, p1),
|
||||
)
|
||||
|
||||
def intersect(self, line: ConstructionLine) -> list[Vec2]:
|
||||
"""Returns 0, 1 or 2 intersection points between `line` and box
|
||||
borderlines.
|
||||
|
||||
Args:
|
||||
line: line to intersect with borderlines
|
||||
|
||||
Returns:
|
||||
list of intersection points
|
||||
|
||||
=========== ==================================
|
||||
list size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 line touches box at one corner
|
||||
2 line intersects with box
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
result = set()
|
||||
for border_line in self.border_lines():
|
||||
p = line.intersect(border_line)
|
||||
if p is not None:
|
||||
result.add(p)
|
||||
return sorted(result)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2018-2022 Manfred Moitzi
|
||||
# License: MIT License
|
||||
# source: http://www.lee-mac.com/bulgeconversion.html
|
||||
# source: http://www.afralisp.net/archive/lisp/Bulges1.htm
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import math
|
||||
from ezdxf.math import Vec2, UVec
|
||||
|
||||
|
||||
__all__ = [
|
||||
"bulge_to_arc",
|
||||
"bulge_3_points",
|
||||
"bulge_center",
|
||||
"bulge_radius",
|
||||
"arc_to_bulge",
|
||||
"bulge_from_radius_and_chord",
|
||||
"bulge_from_arc_angle",
|
||||
]
|
||||
|
||||
|
||||
def polar(p: Any, angle: float, distance: float) -> Vec2:
|
||||
"""Returns the point at a specified `angle` and `distance` from point `p`.
|
||||
|
||||
Args:
|
||||
p: point as :class:`Vec2` compatible object
|
||||
angle: angle in radians
|
||||
distance: distance
|
||||
|
||||
"""
|
||||
return Vec2(p) + Vec2.from_angle(angle, distance)
|
||||
|
||||
|
||||
def angle(p1: Any, p2: Any) -> float:
|
||||
"""Returns angle a line defined by two endpoints and x-axis in radians.
|
||||
|
||||
Args:
|
||||
p1: start point as :class:`Vec2` compatible object
|
||||
p2: end point as :class:`Vec2` compatible object
|
||||
|
||||
"""
|
||||
return (Vec2(p2) - Vec2(p1)).angle
|
||||
|
||||
|
||||
def arc_to_bulge(
|
||||
center: UVec, start_angle: float, end_angle: float, radius: float
|
||||
) -> tuple[Vec2, Vec2, float]:
|
||||
"""Returns bulge parameters from arc parameters.
|
||||
|
||||
Args:
|
||||
center: circle center point as :class:`Vec2` compatible object
|
||||
start_angle: start angle in radians
|
||||
end_angle: end angle in radians
|
||||
radius: circle radius
|
||||
|
||||
Returns:
|
||||
tuple: (start_point, end_point, bulge)
|
||||
|
||||
"""
|
||||
start_point = polar(center, start_angle, radius)
|
||||
end_point = polar(center, end_angle, radius)
|
||||
pi2 = math.pi * 2
|
||||
a = math.fmod((pi2 + (end_angle - start_angle)), pi2) / 4.0
|
||||
bulge = math.sin(a) / math.cos(a)
|
||||
return start_point, end_point, bulge
|
||||
|
||||
|
||||
def bulge_3_points(start_point: UVec, end_point: UVec, point: UVec) -> float:
|
||||
"""Returns bulge value defined by three points.
|
||||
|
||||
Based on 3-Points to Bulge by `Lee Mac`_.
|
||||
|
||||
Args:
|
||||
start_point: start point as :class:`Vec2` compatible object
|
||||
end_point: end point as :class:`Vec2` compatible object
|
||||
point: arbitrary point as :class:`Vec2` compatible object
|
||||
|
||||
"""
|
||||
a = (math.pi - angle(point, start_point) + angle(point, end_point)) / 2
|
||||
return math.sin(a) / math.cos(a)
|
||||
|
||||
|
||||
def bulge_to_arc(
|
||||
start_point: UVec, end_point: UVec, bulge: float
|
||||
) -> tuple[Vec2, float, float, float]:
|
||||
"""Returns arc parameters from bulge parameters.
|
||||
|
||||
The arcs defined by bulge values of :class:`~ezdxf.entities.LWPolyline`
|
||||
and 2D :class:`~ezdxf.entities.Polyline` entities start at the vertex which
|
||||
includes the bulge value and ends at the following vertex.
|
||||
|
||||
.. important::
|
||||
|
||||
The return values always describe a counter-clockwise oriented arc, so for
|
||||
clockwise arcs (negative bulge values) the start and end angles are swapped and
|
||||
the arc starts at the `end_point` and ends at the `start_point`.
|
||||
|
||||
Based on Bulge to Arc by `Lee Mac`_.
|
||||
|
||||
Args:
|
||||
start_point: start vertex as :class:`Vec2` compatible object
|
||||
end_point: end vertex as :class:`Vec2` compatible object
|
||||
bulge: bulge value
|
||||
|
||||
Returns:
|
||||
Tuple: (center, start_angle, end_angle, radius)
|
||||
|
||||
"""
|
||||
r = signed_bulge_radius(start_point, end_point, bulge)
|
||||
a = angle(start_point, end_point) + (math.pi / 2 - math.atan(bulge) * 2)
|
||||
c = polar(start_point, a, r)
|
||||
if bulge < 0:
|
||||
return c, angle(c, end_point), angle(c, start_point), abs(r)
|
||||
else:
|
||||
return c, angle(c, start_point), angle(c, end_point), abs(r)
|
||||
|
||||
|
||||
def bulge_center(start_point: UVec, end_point: UVec, bulge: float) -> Vec2:
|
||||
"""Returns center of arc described by the given bulge parameters.
|
||||
|
||||
Based on Bulge Center by `Lee Mac`_.
|
||||
|
||||
Args:
|
||||
start_point: start point as :class:`Vec2` compatible object
|
||||
end_point: end point as :class:`Vec2` compatible object
|
||||
bulge: bulge value as float
|
||||
|
||||
|
||||
"""
|
||||
start_point = Vec2(start_point)
|
||||
a = angle(start_point, end_point) + (math.pi / 2.0 - math.atan(bulge) * 2.0)
|
||||
return start_point + Vec2.from_angle(
|
||||
a, signed_bulge_radius(start_point, end_point, bulge)
|
||||
)
|
||||
|
||||
|
||||
def signed_bulge_radius(
|
||||
start_point: UVec, end_point: UVec, bulge: float
|
||||
) -> float:
|
||||
return (
|
||||
Vec2(start_point).distance(Vec2(end_point))
|
||||
* (1.0 + (bulge * bulge))
|
||||
/ 4.0
|
||||
/ bulge
|
||||
)
|
||||
|
||||
|
||||
def bulge_radius(start_point: UVec, end_point: UVec, bulge: float) -> float:
|
||||
"""Returns radius of arc defined by the given bulge parameters.
|
||||
|
||||
Based on Bulge Radius by `Lee Mac`_
|
||||
|
||||
Args:
|
||||
start_point: start point as :class:`Vec2` compatible object
|
||||
end_point: end point as :class:`Vec2` compatible object
|
||||
bulge: bulge value
|
||||
|
||||
"""
|
||||
return abs(signed_bulge_radius(start_point, end_point, bulge))
|
||||
|
||||
|
||||
def bulge_from_radius_and_chord(radius: float, chord: float) -> float:
|
||||
"""Returns the bulge value for the given arc radius and chord length.
|
||||
Returns 0 if the radius is zero or the radius is too small for the given
|
||||
chord length to create an arc.
|
||||
|
||||
Args:
|
||||
radius: arc radius
|
||||
chord: chord length
|
||||
|
||||
"""
|
||||
# https://github.com/mozman/ezdxf/discussions/758
|
||||
try:
|
||||
x = chord / (2.0 * radius)
|
||||
except ZeroDivisionError:
|
||||
return 0.0
|
||||
try:
|
||||
return x / (1.0 + math.sqrt(1.0 - x * x))
|
||||
except ValueError: # domain error
|
||||
return 0.0
|
||||
|
||||
|
||||
def bulge_from_arc_angle(angle: float) -> float:
|
||||
"""Returns the bulge value for the given arc angle.
|
||||
|
||||
Args:
|
||||
angle: arc angle in radians
|
||||
|
||||
"""
|
||||
# https://github.com/mozman/ezdxf/discussions/758
|
||||
return math.tan(angle / 4.0)
|
||||
@@ -0,0 +1,249 @@
|
||||
# Copyright (c) 2010-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Iterator, Iterable
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec2, UVec
|
||||
from .line import ConstructionRay, ConstructionLine
|
||||
from .bbox import BoundingBox2d
|
||||
|
||||
|
||||
HALF_PI = math.pi / 2.0
|
||||
|
||||
__all__ = ["ConstructionCircle"]
|
||||
|
||||
|
||||
class ConstructionCircle:
|
||||
"""Construction tool for 2D circles.
|
||||
|
||||
Args:
|
||||
center: center point as :class:`Vec2` compatible object
|
||||
radius: circle radius > `0`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, center: UVec, radius: float = 1.0):
|
||||
self.center = Vec2(center)
|
||||
self.radius = float(radius)
|
||||
if self.radius <= 0.0:
|
||||
raise ValueError("Radius has to be > 0.")
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns string representation of circle
|
||||
"ConstructionCircle(center, radius)".
|
||||
"""
|
||||
return f"ConstructionCircle({self.center}, {self.radius})"
|
||||
|
||||
@staticmethod
|
||||
def from_3p(p1: UVec, p2: UVec, p3: UVec) -> ConstructionCircle:
|
||||
"""Creates a circle from three points, all points have to be compatible
|
||||
to :class:`Vec2` class.
|
||||
"""
|
||||
_p1 = Vec2(p1)
|
||||
_p2 = Vec2(p2)
|
||||
_p3 = Vec2(p3)
|
||||
ray1 = ConstructionRay(_p1, _p2)
|
||||
ray2 = ConstructionRay(_p1, _p3)
|
||||
center_ray1 = ray1.orthogonal(_p1.lerp(_p2))
|
||||
center_ray2 = ray2.orthogonal(_p1.lerp(_p3))
|
||||
center = center_ray1.intersect(center_ray2)
|
||||
return ConstructionCircle(center, center.distance(_p1))
|
||||
|
||||
@property
|
||||
def bounding_box(self) -> BoundingBox2d:
|
||||
"""2D bounding box of circle as :class:`BoundingBox2d` object."""
|
||||
rvec = Vec2((self.radius, self.radius))
|
||||
return BoundingBox2d((self.center - rvec, self.center + rvec))
|
||||
|
||||
def translate(self, dx: float, dy: float) -> None:
|
||||
"""Move circle about `dx` in x-axis and about `dy` in y-axis.
|
||||
|
||||
Args:
|
||||
dx: translation in x-axis
|
||||
dy: translation in y-axis
|
||||
|
||||
"""
|
||||
self.center += Vec2((dx, dy))
|
||||
|
||||
def point_at(self, angle: float) -> Vec2:
|
||||
"""Returns point on circle at `angle` as :class:`Vec2` object.
|
||||
|
||||
Args:
|
||||
angle: angle in radians, angle goes counter
|
||||
clockwise around the z-axis, x-axis = 0 deg.
|
||||
|
||||
"""
|
||||
return self.center + Vec2.from_angle(angle, self.radius)
|
||||
|
||||
def vertices(self, angles: Iterable[float]) -> Iterable[Vec2]:
|
||||
"""Yields vertices of the circle for iterable `angles`.
|
||||
|
||||
Args:
|
||||
angles: iterable of angles as radians, angle goes counter-clockwise
|
||||
around the z-axis, x-axis = 0 deg.
|
||||
|
||||
"""
|
||||
center = self.center
|
||||
radius = self.radius
|
||||
for angle in angles:
|
||||
yield center + Vec2.from_angle(angle, radius)
|
||||
|
||||
def flattening(self, sagitta: float) -> Iterator[Vec2]:
|
||||
"""Approximate the circle by vertices, argument `sagitta` is the
|
||||
max. distance from the center of an arc segment to the center of its
|
||||
chord. Returns a closed polygon where the start vertex is coincident
|
||||
with the end vertex!
|
||||
"""
|
||||
from .arc import arc_segment_count
|
||||
|
||||
count = arc_segment_count(self.radius, math.tau, sagitta)
|
||||
yield from self.vertices(np.linspace(0.0, math.tau, count + 1))
|
||||
|
||||
def inside(self, point: UVec) -> bool:
|
||||
"""Returns ``True`` if `point` is inside circle."""
|
||||
return self.radius >= self.center.distance(Vec2(point))
|
||||
|
||||
def tangent(self, angle: float) -> ConstructionRay:
|
||||
"""Returns tangent to circle at `angle` as :class:`ConstructionRay`
|
||||
object.
|
||||
|
||||
Args:
|
||||
angle: angle in radians
|
||||
|
||||
"""
|
||||
point_on_circle = self.point_at(angle)
|
||||
ray = ConstructionRay(self.center, point_on_circle)
|
||||
return ray.orthogonal(point_on_circle)
|
||||
|
||||
def intersect_ray(
|
||||
self, ray: ConstructionRay, abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of circle and `ray` as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
ray: intersection ray
|
||||
abs_tol: absolute tolerance for tests (e.g. test for tangents)
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 ray is a tangent to circle
|
||||
2 ray intersects with the circle
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(ray, ConstructionRay)
|
||||
ortho_ray = ray.orthogonal(self.center)
|
||||
intersection_point = ray.intersect(ortho_ray)
|
||||
dist = self.center.distance(intersection_point)
|
||||
result = []
|
||||
# Intersect in two points:
|
||||
if dist < self.radius:
|
||||
# Ray goes through center point:
|
||||
if math.isclose(dist, 0.0, abs_tol=abs_tol):
|
||||
angle = ortho_ray.angle
|
||||
alpha = HALF_PI
|
||||
else:
|
||||
# The exact direction of angle (all 4 quadrants Q1-Q4) is
|
||||
# important: ortho_ray.angle is only correct at the center point
|
||||
angle = (intersection_point - self.center).angle
|
||||
alpha = math.acos(
|
||||
intersection_point.distance(self.center) / self.radius
|
||||
)
|
||||
result.append(self.point_at(angle + alpha))
|
||||
result.append(self.point_at(angle - alpha))
|
||||
# Ray is a tangent of the circle:
|
||||
elif math.isclose(dist, self.radius, abs_tol=abs_tol):
|
||||
result.append(intersection_point)
|
||||
# else: No intersection
|
||||
return tuple(result)
|
||||
|
||||
def intersect_line(
|
||||
self, line: ConstructionLine, abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of circle and `line` as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
line: intersection line
|
||||
abs_tol: absolute tolerance for tests (e.g. test for tangents)
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 line intersects or touches the circle at one point
|
||||
2 line intersects the circle at two points
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(line, ConstructionLine)
|
||||
return [
|
||||
point
|
||||
for point in self.intersect_ray(line.ray, abs_tol=abs_tol)
|
||||
if is_point_in_line_range(line.start, line.end, point)
|
||||
]
|
||||
|
||||
def intersect_circle(
|
||||
self, other: "ConstructionCircle", abs_tol: float = 1e-10
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns intersection points of two circles as sequence of
|
||||
:class:`Vec2` objects.
|
||||
|
||||
Args:
|
||||
other: intersection circle
|
||||
abs_tol: absolute tolerance for tests
|
||||
|
||||
Returns:
|
||||
tuple of :class:`Vec2` objects
|
||||
|
||||
=========== ==================================
|
||||
tuple size Description
|
||||
=========== ==================================
|
||||
0 no intersection
|
||||
1 circle touches the `other` circle at one point
|
||||
2 circle intersects with the `other` circle
|
||||
=========== ==================================
|
||||
|
||||
"""
|
||||
assert isinstance(other, ConstructionCircle)
|
||||
r1 = self.radius
|
||||
r2 = other.radius
|
||||
d = self.center.distance(other.center)
|
||||
if d < abs_tol:
|
||||
# concentric circles do not intersect by definition
|
||||
return tuple()
|
||||
|
||||
d_max = r1 + r2
|
||||
d_min = math.fabs(r1 - r2)
|
||||
if d_min <= d <= d_max:
|
||||
angle = (other.center - self.center).angle
|
||||
# Circles touches at one point:
|
||||
if math.isclose(d, d_max, abs_tol=abs_tol):
|
||||
return (self.point_at(angle),)
|
||||
if math.isclose(d, d_min, abs_tol=abs_tol):
|
||||
if r1 >= r2:
|
||||
return (self.point_at(angle),)
|
||||
return (self.point_at(angle + math.pi),)
|
||||
else: # Circles intersect in two points:
|
||||
# Law of Cosines:
|
||||
alpha = math.acos((r2 * r2 - r1 * r1 - d * d) / (-2.0 * r1 * d))
|
||||
return tuple(self.vertices((angle + alpha, angle - alpha)))
|
||||
return tuple()
|
||||
|
||||
|
||||
def is_point_in_line_range(start: Vec2, end: Vec2, point: Vec2) -> bool:
|
||||
length = (end - start).magnitude
|
||||
if (point - start).magnitude > length:
|
||||
return False
|
||||
return (point - end).magnitude <= length
|
||||
@@ -0,0 +1,938 @@
|
||||
# Copyright (c) 2021-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence, Optional, Iterator, Callable
|
||||
from typing_extensions import Protocol
|
||||
import math
|
||||
import enum
|
||||
|
||||
from ezdxf.math import (
|
||||
Vec2,
|
||||
UVec,
|
||||
intersection_line_line_2d,
|
||||
is_point_in_polygon_2d,
|
||||
has_clockwise_orientation,
|
||||
point_to_line_relation,
|
||||
TOLERANCE,
|
||||
BoundingBox2d,
|
||||
)
|
||||
from ezdxf.tools import take2, pairwise
|
||||
|
||||
|
||||
__all__ = [
|
||||
"greiner_hormann_union",
|
||||
"greiner_hormann_difference",
|
||||
"greiner_hormann_intersection",
|
||||
"Clipping",
|
||||
"ConvexClippingPolygon2d",
|
||||
"ConcaveClippingPolygon2d",
|
||||
"ClippingRect2d",
|
||||
"InvertedClippingPolygon2d",
|
||||
"CohenSutherlandLineClipping2d",
|
||||
]
|
||||
|
||||
|
||||
class Clipping(Protocol):
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon."""
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the parts of the clipped line."""
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping path."""
|
||||
|
||||
|
||||
def _clip_polyline(
|
||||
polyline: Sequence[Vec2],
|
||||
line_clipper: Callable[[Vec2, Vec2], Sequence[tuple[Vec2, Vec2]]],
|
||||
abs_tol: float,
|
||||
) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
if len(polyline) < 2:
|
||||
return []
|
||||
result: list[Vec2] = []
|
||||
parts: list[list[Vec2]] = []
|
||||
next_start = polyline[0]
|
||||
for end in polyline[1:]:
|
||||
start = next_start
|
||||
next_start = end
|
||||
for clipped_line in line_clipper(start, end):
|
||||
if len(clipped_line) != 2:
|
||||
continue
|
||||
if result:
|
||||
clip_start, clip_end = clipped_line
|
||||
if result[-1].isclose(clip_start, abs_tol=abs_tol):
|
||||
result.append(clip_end)
|
||||
continue
|
||||
parts.append(result)
|
||||
result = list(clipped_line)
|
||||
if result:
|
||||
parts.append(result)
|
||||
return parts
|
||||
|
||||
|
||||
class ConvexClippingPolygon2d:
|
||||
"""The clipping path is an arbitrary convex 2D polygon."""
|
||||
|
||||
def __init__(self, vertices: Iterable[Vec2], ccw_check=True, abs_tol=TOLERANCE):
|
||||
self.abs_tol = abs_tol
|
||||
clip = list(vertices)
|
||||
if len(clip) > 1:
|
||||
if clip[0].isclose(clip[-1], abs_tol=self.abs_tol):
|
||||
clip.pop()
|
||||
if len(clip) < 3:
|
||||
raise ValueError("more than 3 vertices as clipping polygon required")
|
||||
if ccw_check and has_clockwise_orientation(clip):
|
||||
clip.reverse()
|
||||
self._clipping_polygon: list[Vec2] = clip
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
return _clip_polyline(polyline, self.clip_line, abs_tol=self.abs_tol)
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the parts of the clipped line."""
|
||||
|
||||
def is_inside(point: Vec2) -> bool:
|
||||
# is point left of line:
|
||||
return (clip_end.x - clip_start.x) * (point.y - clip_start.y) - (
|
||||
clip_end.y - clip_start.y
|
||||
) * (point.x - clip_start.x) >= 0.0
|
||||
|
||||
def edge_intersection(default: Vec2) -> Vec2:
|
||||
ip = intersection_line_line_2d(
|
||||
(edge_start, edge_end), (clip_start, clip_end), abs_tol=self.abs_tol
|
||||
)
|
||||
if ip is None:
|
||||
return default
|
||||
return ip
|
||||
|
||||
# The clipping polygon is always treated as a closed polyline!
|
||||
clip_start = self._clipping_polygon[-1]
|
||||
edge_start = start
|
||||
edge_end = end
|
||||
for clip_end in self._clipping_polygon:
|
||||
if is_inside(edge_start):
|
||||
if not is_inside(edge_end):
|
||||
edge_end = edge_intersection(edge_end)
|
||||
elif is_inside(edge_end):
|
||||
if not is_inside(edge_start):
|
||||
edge_start = edge_intersection(edge_start)
|
||||
else:
|
||||
return tuple()
|
||||
clip_start = clip_end
|
||||
return ((edge_start, edge_end),)
|
||||
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon. A polygon is a closed polyline."""
|
||||
|
||||
def is_inside(point: Vec2) -> bool:
|
||||
# is point left of line:
|
||||
return (clip_end.x - clip_start.x) * (point.y - clip_start.y) - (
|
||||
clip_end.y - clip_start.y
|
||||
) * (point.x - clip_start.x) > 0.0
|
||||
|
||||
def edge_intersection() -> None:
|
||||
ip = intersection_line_line_2d(
|
||||
(edge_start, edge_end), (clip_start, clip_end), abs_tol=self.abs_tol
|
||||
)
|
||||
if ip is not None:
|
||||
clipped.append(ip)
|
||||
|
||||
# The clipping polygon is always treated as a closed polyline!
|
||||
clip_start = self._clipping_polygon[-1]
|
||||
clipped = list(polygon)
|
||||
for clip_end in self._clipping_polygon:
|
||||
# next clipping edge to test: clip_start -> clip_end
|
||||
if not clipped: # no subject vertices left to test
|
||||
break
|
||||
|
||||
vertices = clipped.copy()
|
||||
if len(vertices) > 1 and vertices[0].isclose(
|
||||
vertices[-1], abs_tol=self.abs_tol
|
||||
):
|
||||
vertices.pop()
|
||||
|
||||
clipped.clear()
|
||||
edge_start = vertices[-1]
|
||||
for edge_end in vertices:
|
||||
# next polygon edge to test: edge_start -> edge_end
|
||||
if is_inside(edge_end):
|
||||
if not is_inside(edge_start):
|
||||
edge_intersection()
|
||||
clipped.append(edge_end)
|
||||
elif is_inside(edge_start):
|
||||
edge_intersection()
|
||||
edge_start = edge_end
|
||||
clip_start = clip_end
|
||||
return (clipped,)
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping polygon."""
|
||||
return is_point_in_polygon_2d(point, self._clipping_polygon) >= 0
|
||||
|
||||
|
||||
class ClippingRect2d:
|
||||
"""The clipping path is an axis-aligned rectangle, where all sides are parallel to
|
||||
the x- and y-axis.
|
||||
"""
|
||||
|
||||
def __init__(self, bottom_left: Vec2, top_right: Vec2, abs_tol=TOLERANCE):
|
||||
self.abs_tol = abs_tol
|
||||
self._bbox = BoundingBox2d((bottom_left, top_right))
|
||||
bottom_left = self._bbox.extmin
|
||||
top_right = self._bbox.extmax
|
||||
self._clipping_polygon = ConvexClippingPolygon2d(
|
||||
[
|
||||
bottom_left,
|
||||
Vec2(top_right.x, bottom_left.y),
|
||||
top_right,
|
||||
Vec2(bottom_left.x, top_right.y),
|
||||
],
|
||||
ccw_check=False,
|
||||
abs_tol=self.abs_tol,
|
||||
)
|
||||
self._line_clipper = CohenSutherlandLineClipping2d(
|
||||
self._bbox.extmin, self._bbox.extmax
|
||||
)
|
||||
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon. A polygon is a closed polyline."""
|
||||
return self._clipping_polygon.clip_polygon(polygon)
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
return _clip_polyline(polyline, self.clip_line, self.abs_tol)
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the clipped line."""
|
||||
result = self._line_clipper.clip_line(start, end)
|
||||
if len(result) == 2:
|
||||
return (result,) # type: ignore
|
||||
return tuple()
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping rectangle."""
|
||||
return self._bbox.inside(point)
|
||||
|
||||
def has_intersection(self, other: BoundingBox2d) -> bool:
|
||||
"""Returns ``True`` if `other` bounding box intersects the clipping rectangle."""
|
||||
return self._bbox.has_intersection(other)
|
||||
|
||||
|
||||
class ConcaveClippingPolygon2d:
|
||||
"""The clipping path is an arbitrary concave 2D polygon."""
|
||||
|
||||
def __init__(self, vertices: Iterable[Vec2], abs_tol=TOLERANCE):
|
||||
self.abs_tol = abs_tol
|
||||
clip = list(vertices)
|
||||
if len(clip) > 1:
|
||||
if clip[0].isclose(clip[-1], abs_tol=self.abs_tol):
|
||||
clip.pop()
|
||||
if len(clip) < 3:
|
||||
raise ValueError("more than 3 vertices as clipping polygon required")
|
||||
# open polygon; clockwise or counter-clockwise oriented vertices
|
||||
self._clipping_polygon = clip
|
||||
self._bbox = BoundingBox2d(clip)
|
||||
|
||||
def is_inside(self, point: Vec2) -> bool:
|
||||
"""Returns ``True`` if `point` is inside the clipping polygon."""
|
||||
if not self._bbox.inside(point):
|
||||
return False
|
||||
return (
|
||||
is_point_in_polygon_2d(point, self._clipping_polygon, abs_tol=self.abs_tol)
|
||||
>= 0
|
||||
)
|
||||
|
||||
def clip_line(self, start: Vec2, end: Vec2) -> Sequence[tuple[Vec2, Vec2]]:
|
||||
"""Returns the clipped line."""
|
||||
abs_tol = self.abs_tol
|
||||
line = (start, end)
|
||||
if not self._bbox.has_overlap(BoundingBox2d(line)):
|
||||
return tuple()
|
||||
|
||||
intersections = polygon_line_intersections_2d(self._clipping_polygon, line)
|
||||
start_is_inside = is_point_in_polygon_2d(start, self._clipping_polygon) >= 0
|
||||
if len(intersections) == 0:
|
||||
if start_is_inside:
|
||||
return (line,)
|
||||
return tuple()
|
||||
end_is_inside = (
|
||||
is_point_in_polygon_2d(end, self._clipping_polygon, abs_tol=abs_tol) >= 0
|
||||
)
|
||||
if end_is_inside and not intersections[-1].isclose(end, abs_tol=abs_tol):
|
||||
# last inside-segment ends at end
|
||||
intersections.append(end)
|
||||
if start_is_inside and not intersections[0].isclose(start, abs_tol=abs_tol):
|
||||
# first inside-segment begins at start
|
||||
intersections.insert(0, start)
|
||||
|
||||
# REMOVE duplicate intersection points at the beginning and the end -
|
||||
# these are caused by clipping at the connection point of two edges.
|
||||
# KEEP duplicate intersection points in between - these are caused by the
|
||||
# coincident edges of inverted clipping polygons. These intersections points
|
||||
# are required for the inside/outside rule to work properly!
|
||||
if len(intersections) > 1 and intersections[0].isclose(
|
||||
intersections[1], abs_tol=abs_tol
|
||||
):
|
||||
intersections.pop(0)
|
||||
if len(intersections) > 1 and intersections[-1].isclose(
|
||||
intersections[-2], abs_tol=abs_tol
|
||||
):
|
||||
intersections.pop()
|
||||
|
||||
if has_collinear_edge(self._clipping_polygon, start, end):
|
||||
# slow detection: doesn't work with inside/outside rule!
|
||||
# test if mid-point of intersection-segment is inside the polygon.
|
||||
# intersection-segment collinear with a polygon edge is inside!
|
||||
segments: list[tuple[Vec2, Vec2]] = []
|
||||
for a, b in pairwise(intersections):
|
||||
if a.isclose(b, abs_tol=abs_tol): # ignore zero-length segments
|
||||
continue
|
||||
if (
|
||||
is_point_in_polygon_2d(
|
||||
a.lerp(b), self._clipping_polygon, abs_tol=abs_tol
|
||||
)
|
||||
>= 0
|
||||
):
|
||||
segments.append((a, b))
|
||||
return segments
|
||||
|
||||
# inside/outside rule
|
||||
# intersection segments:
|
||||
# (0, 1) outside (2, 3) outside (4, 5) ...
|
||||
return list(take2(intersections))
|
||||
|
||||
def clip_polyline(self, polyline: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polyline."""
|
||||
abs_tol = self.abs_tol
|
||||
segments: list[list[Vec2]] = []
|
||||
for start, end in pairwise(polyline):
|
||||
for a, b in self.clip_line(start, end):
|
||||
if segments:
|
||||
last_seg = segments[-1]
|
||||
if last_seg[-1].isclose(a, abs_tol=abs_tol):
|
||||
last_seg.append(b)
|
||||
continue
|
||||
segments.append([a, b])
|
||||
return segments
|
||||
|
||||
def clip_polygon(self, polygon: Sequence[Vec2]) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped polygon. A polygon is a closed polyline."""
|
||||
vertices = list(polygon)
|
||||
abs_tol = self.abs_tol
|
||||
if len(vertices) > 1:
|
||||
if vertices[0].isclose(vertices[-1], abs_tol=abs_tol):
|
||||
vertices.pop()
|
||||
if len(vertices) < 3:
|
||||
return tuple()
|
||||
polygon_box = BoundingBox2d(vertices)
|
||||
if not self._bbox.has_intersection(polygon_box):
|
||||
return tuple() # polygons do not overlap
|
||||
result = clip_arbitrary_polygons(self._clipping_polygon, vertices)
|
||||
if len(result) == 0:
|
||||
is_outside = any(
|
||||
is_point_in_polygon_2d(v, self._clipping_polygon, abs_tol=abs_tol) < 0
|
||||
for v in vertices
|
||||
)
|
||||
if is_outside:
|
||||
return tuple()
|
||||
return (vertices,)
|
||||
# return (self._clipping_polygon.copy(),)
|
||||
return result
|
||||
|
||||
|
||||
def clip_arbitrary_polygons(
|
||||
clipper: list[Vec2], subject: list[Vec2]
|
||||
) -> Sequence[Sequence[Vec2]]:
|
||||
"""Returns the parts of the clipped subject. Both polygons can be concave
|
||||
|
||||
Args:
|
||||
clipper: clipping window closed polygon
|
||||
subject: closed polygon to clip
|
||||
|
||||
"""
|
||||
# Caching of gh_clipper is not possible, because both GHPolygons get modified!
|
||||
gh_clipper = GHPolygon.from_vec2(clipper)
|
||||
gh_subject = GHPolygon.from_vec2(subject)
|
||||
return gh_clipper.intersection(gh_subject)
|
||||
|
||||
|
||||
def has_collinear_edge(polygon: list[Vec2], start: Vec2, end: Vec2) -> bool:
|
||||
"""Returns ``True`` if `polygon` has any collinear edge to line `start->end`."""
|
||||
a = polygon[-1]
|
||||
rel_a = point_to_line_relation(a, start, end)
|
||||
for b in polygon:
|
||||
rel_b = point_to_line_relation(b, start, end)
|
||||
if rel_a == 0 and rel_b == 0:
|
||||
return True
|
||||
a = b
|
||||
rel_a = rel_b
|
||||
return False
|
||||
|
||||
|
||||
def polygon_line_intersections_2d(
|
||||
polygon: list[Vec2], line: tuple[Vec2, Vec2], abs_tol: float = TOLERANCE
|
||||
) -> list[Vec2]:
|
||||
"""Returns all intersections of polygon with line.
|
||||
All intersections points are ordered from start to end of line.
|
||||
Start and end points are not included if not explicit intersection points.
|
||||
|
||||
.. Note::
|
||||
|
||||
Returns duplicate intersections points when the line intersects at
|
||||
the connection point of two polygon edges!
|
||||
|
||||
"""
|
||||
intersection_points: list[Vec2] = []
|
||||
start, end = line
|
||||
size = len(polygon)
|
||||
for index in range(size):
|
||||
a = polygon[index - 1]
|
||||
b = polygon[index]
|
||||
ip = intersection_line_line_2d((a, b), line, virtual=False, abs_tol=abs_tol)
|
||||
if ip is None:
|
||||
continue
|
||||
# Note: do not remove duplicate vertices, because inverted clipping polygons
|
||||
# have coincident clipping edges inside the clipping polygon! #1101
|
||||
if ip.isclose(a, abs_tol=abs_tol):
|
||||
a_prev = polygon[index - 2]
|
||||
rel_prev = point_to_line_relation(a_prev, start, end, abs_tol=abs_tol)
|
||||
rel_next = point_to_line_relation(b, start, end, abs_tol=abs_tol)
|
||||
if rel_prev == rel_next:
|
||||
continue
|
||||
# edge case: line intersects "exact" in point b
|
||||
elif ip.isclose(b, abs_tol=abs_tol):
|
||||
b_next = polygon[(index + 1) % size]
|
||||
rel_prev = point_to_line_relation(a, start, end, abs_tol=abs_tol)
|
||||
rel_next = point_to_line_relation(b_next, start, end, abs_tol=abs_tol)
|
||||
if rel_prev == rel_next:
|
||||
continue
|
||||
intersection_points.append(ip)
|
||||
|
||||
intersection_points.sort(key=lambda ip: ip.distance(start))
|
||||
return intersection_points
|
||||
|
||||
|
||||
class InvertedClippingPolygon2d(ConcaveClippingPolygon2d):
|
||||
"""This class represents an inverted clipping path. Everything between the inner
|
||||
polygon and the outer extents is considered as inside. The inner clipping path is
|
||||
an arbitrary 2D polygon.
|
||||
|
||||
.. Important::
|
||||
|
||||
The `outer_bounds` must be larger than the content to clip to work correctly.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner_polygon: Iterable[Vec2],
|
||||
outer_bounds: BoundingBox2d,
|
||||
abs_tol=TOLERANCE,
|
||||
):
|
||||
self.abs_tol = abs_tol
|
||||
clip = list(inner_polygon)
|
||||
if len(clip) > 1:
|
||||
if not clip[0].isclose(clip[-1], abs_tol=abs_tol): # close inner_polygon
|
||||
clip.append(clip[0])
|
||||
if len(clip) < 4:
|
||||
raise ValueError("more than 3 vertices as clipping polygon required")
|
||||
# requirements for inner_polygon:
|
||||
# arbitrary polygon (convex or concave)
|
||||
# closed polygon (first vertex == last vertex)
|
||||
# clockwise or counter-clockwise oriented vertices
|
||||
self._clipping_polygon = make_inverted_clipping_polygon(
|
||||
clip, outer_bounds, abs_tol
|
||||
)
|
||||
self._bbox = outer_bounds
|
||||
|
||||
|
||||
def make_inverted_clipping_polygon(
|
||||
inner_polygon: list[Vec2], outer_bounds: BoundingBox2d, abs_tol=TOLERANCE
|
||||
) -> list[Vec2]:
|
||||
"""Creates a closed inverted clipping polygon by connecting the inner polygon with
|
||||
the surrounding rectangle at their closest vertices.
|
||||
"""
|
||||
assert outer_bounds.has_data is True
|
||||
inner_polygon = inner_polygon.copy()
|
||||
if inner_polygon[0].isclose(inner_polygon[-1], abs_tol=abs_tol):
|
||||
inner_polygon.pop()
|
||||
assert len(inner_polygon) > 2
|
||||
outer_rect = list(outer_bounds.rect_vertices()) # counter-clockwise
|
||||
outer_rect.reverse() # clockwise
|
||||
ci, co = find_closest_vertices(inner_polygon, outer_rect)
|
||||
result = inner_polygon[ci:]
|
||||
result.extend(inner_polygon[: ci + 1])
|
||||
result.extend(outer_rect[co:])
|
||||
result.extend(outer_rect[: co + 1])
|
||||
result.append(result[0])
|
||||
return result
|
||||
|
||||
|
||||
def find_closest_vertices(
|
||||
vertices0: list[Vec2], vertices1: list[Vec2]
|
||||
) -> tuple[int, int]:
|
||||
"""Returns the indices of the closest vertices of both lists."""
|
||||
min_dist = math.inf
|
||||
result: tuple[int, int] = 0, 0
|
||||
for i0, v0 in enumerate(vertices0):
|
||||
for i1, v1 in enumerate(vertices1):
|
||||
distance = v0.distance(v1)
|
||||
if distance < min_dist:
|
||||
min_dist = distance
|
||||
result = i0, i1
|
||||
return result
|
||||
|
||||
|
||||
# Based on the paper "Efficient Clipping of Arbitrary Polygons" by
|
||||
# Günther Greiner and Kai Hormann,
|
||||
# ACM Transactions on Graphics 1998;17(2):71-83
|
||||
# Available at: http://www.inf.usi.ch/hormann/papers/Greiner.1998.ECO.pdf
|
||||
|
||||
|
||||
class _Node:
|
||||
def __init__(
|
||||
self,
|
||||
vtx: Vec2,
|
||||
alpha: float = 0.0,
|
||||
intersect=False,
|
||||
entry=True,
|
||||
checked=False,
|
||||
):
|
||||
self.vtx = vtx
|
||||
|
||||
# Reference to the next vertex of the polygon
|
||||
self.next: _Node = None # type: ignore
|
||||
|
||||
# Reference to the previous vertex of the polygon
|
||||
self.prev: _Node = None # type: ignore
|
||||
|
||||
# Reference to the corresponding intersection vertex in the other polygon
|
||||
self.neighbor: _Node = None # type: ignore
|
||||
|
||||
# True if intersection is an entry point, False if exit
|
||||
self.entry: bool = entry
|
||||
|
||||
# Intersection point's relative distance from previous vertex
|
||||
self.alpha: float = alpha
|
||||
|
||||
# True if vertex is an intersection
|
||||
self.intersect: bool = intersect
|
||||
|
||||
# True if the vertex has been checked (last phase)
|
||||
self.checked: bool = checked
|
||||
|
||||
def set_checked(self):
|
||||
self.checked = True
|
||||
if self.neighbor and not self.neighbor.checked:
|
||||
self.neighbor.set_checked()
|
||||
|
||||
|
||||
class IntersectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GHPolygon:
|
||||
first: _Node = None # type: ignore
|
||||
max_x: float = 1e6
|
||||
|
||||
def add(self, node: _Node):
|
||||
"""Add a polygon vertex node."""
|
||||
|
||||
self.max_x = max(self.max_x, node.vtx.x)
|
||||
if self.first is None:
|
||||
self.first = node
|
||||
self.first.next = node
|
||||
self.first.prev = node
|
||||
else: # insert as last node
|
||||
first = self.first
|
||||
last = first.prev
|
||||
first.prev = node
|
||||
node.next = first
|
||||
node.prev = last
|
||||
last.next = node
|
||||
|
||||
@staticmethod
|
||||
def build(vertices: Iterable[UVec]) -> GHPolygon:
|
||||
"""Build a new GHPolygon from an iterable of vertices."""
|
||||
return GHPolygon.from_vec2(Vec2.list(vertices))
|
||||
|
||||
@staticmethod
|
||||
def from_vec2(vertices: Sequence[Vec2]) -> GHPolygon:
|
||||
"""Build a new GHPolygon from an iterable of vertices."""
|
||||
polygon = GHPolygon()
|
||||
for v in vertices:
|
||||
polygon.add(_Node(v))
|
||||
return polygon
|
||||
|
||||
@staticmethod
|
||||
def insert(vertex: _Node, start: _Node, end: _Node):
|
||||
"""Insert and sort an intersection node.
|
||||
|
||||
This function inserts an intersection node between two other
|
||||
start- and end node of an edge. The start and end node cannot be
|
||||
an intersection node (that is, they must be actual vertex nodes of
|
||||
the original polygon). If there are multiple intersection nodes
|
||||
between the start- and end node, then the new node is inserted
|
||||
based on its alpha value.
|
||||
"""
|
||||
curr = start
|
||||
while curr != end and curr.alpha < vertex.alpha:
|
||||
curr = curr.next
|
||||
|
||||
vertex.next = curr
|
||||
prev = curr.prev
|
||||
vertex.prev = prev
|
||||
prev.next = vertex
|
||||
curr.prev = vertex
|
||||
|
||||
def __iter__(self) -> Iterator[_Node]:
|
||||
assert self.first is not None
|
||||
s = self.first
|
||||
while True:
|
||||
yield s
|
||||
s = s.next
|
||||
if s is self.first:
|
||||
return
|
||||
|
||||
@property
|
||||
def first_intersect(self) -> Optional[_Node]:
|
||||
for v in self:
|
||||
if v.intersect and not v.checked:
|
||||
return v
|
||||
return None
|
||||
|
||||
@property
|
||||
def points(self) -> list[Vec2]:
|
||||
points = [v.vtx for v in self]
|
||||
if not points[0].isclose(points[-1]):
|
||||
points.append(points[0])
|
||||
return points
|
||||
|
||||
def unprocessed(self):
|
||||
for v in self:
|
||||
if v.intersect and not v.checked:
|
||||
return True
|
||||
return False
|
||||
|
||||
def union(self, clip: GHPolygon) -> list[list[Vec2]]:
|
||||
return self.clip(clip, False, False)
|
||||
|
||||
def intersection(self, clip: GHPolygon) -> list[list[Vec2]]:
|
||||
return self.clip(clip, True, True)
|
||||
|
||||
def difference(self, clip: GHPolygon) -> list[list[Vec2]]:
|
||||
return self.clip(clip, False, True)
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def clip(self, clip: GHPolygon, s_entry, c_entry) -> list[list[Vec2]]:
|
||||
"""Clip this polygon using another one as a clipper.
|
||||
|
||||
This is where the algorithm is executed. It allows you to make
|
||||
a UNION, INTERSECT or DIFFERENCE operation between two polygons.
|
||||
|
||||
Given two polygons A, B the following operations may be performed:
|
||||
|
||||
A|B ... A OR B (Union of A and B)
|
||||
A&B ... A AND B (Intersection of A and B)
|
||||
A\\B ... A - B
|
||||
B\\A ... B - A
|
||||
|
||||
The entry records store the direction the algorithm should take when
|
||||
it arrives at that entry point in an intersection. Depending on the
|
||||
operation requested, the direction is set as follows for entry points
|
||||
(f=forward, b=backward; exit points are always set to the opposite):
|
||||
|
||||
Entry
|
||||
A B
|
||||
-----
|
||||
A|B b b
|
||||
A&B f f
|
||||
A\\B b f
|
||||
B\\A f b
|
||||
|
||||
f = True, b = False when stored in the entry record
|
||||
"""
|
||||
# Phase 1: Find intersections
|
||||
for subject_vertex in self:
|
||||
if not subject_vertex.intersect:
|
||||
for clipper_vertex in clip:
|
||||
if not clipper_vertex.intersect:
|
||||
ip, us, uc = line_intersection(
|
||||
subject_vertex.vtx,
|
||||
next_vertex_node(subject_vertex.next).vtx,
|
||||
clipper_vertex.vtx,
|
||||
next_vertex_node(clipper_vertex.next).vtx,
|
||||
)
|
||||
if ip is None:
|
||||
continue
|
||||
subject_node = _Node(ip, us, intersect=True, entry=False)
|
||||
clipper_node = _Node(ip, uc, intersect=True, entry=False)
|
||||
subject_node.neighbor = clipper_node
|
||||
clipper_node.neighbor = subject_node
|
||||
|
||||
self.insert(
|
||||
subject_node,
|
||||
subject_vertex,
|
||||
next_vertex_node(subject_vertex.next),
|
||||
)
|
||||
clip.insert(
|
||||
clipper_node,
|
||||
clipper_vertex,
|
||||
next_vertex_node(clipper_vertex.next),
|
||||
)
|
||||
|
||||
# Phase 2: Identify entry/exit points
|
||||
s_entry ^= is_inside_polygon(self.first.vtx, clip)
|
||||
for subject_vertex in self:
|
||||
if subject_vertex.intersect:
|
||||
subject_vertex.entry = s_entry
|
||||
s_entry = not s_entry
|
||||
|
||||
c_entry ^= is_inside_polygon(clip.first.vtx, self)
|
||||
for clipper_vertex in clip:
|
||||
if clipper_vertex.intersect:
|
||||
clipper_vertex.entry = c_entry
|
||||
c_entry = not c_entry
|
||||
|
||||
# Phase 3: Construct clipped polygons
|
||||
clipped_polygons: list[list[Vec2]] = []
|
||||
while self.unprocessed():
|
||||
current: _Node = self.first_intersect # type: ignore
|
||||
clipped: list[Vec2] = [current.vtx]
|
||||
while True:
|
||||
current.set_checked()
|
||||
if current.entry:
|
||||
while True:
|
||||
current = current.next
|
||||
clipped.append(current.vtx)
|
||||
if current.intersect:
|
||||
break
|
||||
else:
|
||||
while True:
|
||||
current = current.prev
|
||||
clipped.append(current.vtx)
|
||||
if current.intersect:
|
||||
break
|
||||
|
||||
current = current.neighbor
|
||||
if current.checked:
|
||||
break
|
||||
clipped_polygons.append(clipped)
|
||||
return clipped_polygons
|
||||
|
||||
|
||||
def next_vertex_node(v: _Node) -> _Node:
|
||||
"""Return the next non-intersecting vertex after the one specified."""
|
||||
c = v
|
||||
while c.intersect:
|
||||
c = c.next
|
||||
return c
|
||||
|
||||
|
||||
def is_inside_polygon(vertex: Vec2, polygon: GHPolygon) -> bool:
|
||||
"""Returns ``True`` if `vertex` is inside `polygon`."""
|
||||
# Possible issue: are points on the boundary inside or outside the polygon?
|
||||
# this version: inside
|
||||
return is_point_in_polygon_2d(vertex, polygon.points, abs_tol=TOLERANCE) >= 0
|
||||
|
||||
|
||||
_ERROR = None, 0, 0
|
||||
|
||||
|
||||
def line_intersection(
|
||||
s1: Vec2, s2: Vec2, c1: Vec2, c2: Vec2, tol: float = TOLERANCE
|
||||
) -> tuple[Optional[Vec2], float, float]:
|
||||
"""Returns the intersection point between two lines.
|
||||
|
||||
This special implementation excludes the line end points as intersection
|
||||
points!
|
||||
|
||||
Algorithm based on: http://paulbourke.net/geometry/lineline2d/
|
||||
"""
|
||||
den = (c2.y - c1.y) * (s2.x - s1.x) - (c2.x - c1.x) * (s2.y - s1.y)
|
||||
if abs(den) < tol:
|
||||
return _ERROR
|
||||
us = ((c2.x - c1.x) * (s1.y - c1.y) - (c2.y - c1.y) * (s1.x - c1.x)) / den
|
||||
lwr = 0.0 + tol
|
||||
upr = 1.0 - tol
|
||||
# Line end points are excluded as intersection points:
|
||||
# us =~ 0.0; us =~ 1.0
|
||||
if not (lwr < us < upr):
|
||||
return _ERROR
|
||||
# uc =~ 0.0; uc =~ 1.0
|
||||
uc = ((s2.x - s1.x) * (s1.y - c1.y) - (s2.y - s1.y) * (s1.x - c1.x)) / den
|
||||
if lwr < uc < upr:
|
||||
return (
|
||||
Vec2(s1.x + us * (s2.x - s1.x), s1.y + us * (s2.y - s1.y)),
|
||||
us,
|
||||
uc,
|
||||
)
|
||||
return _ERROR
|
||||
|
||||
|
||||
class BooleanOperation(enum.Enum):
|
||||
UNION = "union"
|
||||
DIFFERENCE = "difference"
|
||||
INTERSECTION = "intersection"
|
||||
|
||||
|
||||
def greiner_hormann_intersection(
|
||||
p1: Iterable[UVec], p2: Iterable[UVec]
|
||||
) -> list[list[Vec2]]:
|
||||
"""Returns the INTERSECTION of polygon `p1` & polygon `p2`.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
return greiner_hormann(p1, p2, BooleanOperation.INTERSECTION)
|
||||
|
||||
|
||||
def greiner_hormann_difference(
|
||||
p1: Iterable[UVec], p2: Iterable[UVec]
|
||||
) -> list[list[Vec2]]:
|
||||
"""Returns the DIFFERENCE of polygon `p1` - polygon `p2`.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
return greiner_hormann(p1, p2, BooleanOperation.DIFFERENCE)
|
||||
|
||||
|
||||
def greiner_hormann_union(p1: Iterable[UVec], p2: Iterable[UVec]) -> list[list[Vec2]]:
|
||||
"""Returns the UNION of polygon `p1` | polygon `p2`.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
return greiner_hormann(p1, p2, BooleanOperation.UNION)
|
||||
|
||||
|
||||
def greiner_hormann(
|
||||
p1: Iterable[UVec], p2: Iterable[UVec], op: BooleanOperation
|
||||
) -> list[list[Vec2]]:
|
||||
"""Implements a 2d clipping function to perform 3 boolean operations:
|
||||
|
||||
- UNION: p1 | p2 ... p1 OR p2
|
||||
- INTERSECTION: p1 & p2 ... p1 AND p2
|
||||
- DIFFERENCE: p1 \\ p2 ... p1 - p2
|
||||
|
||||
Based on the paper "Efficient Clipping of Arbitrary Polygons" by
|
||||
Günther Greiner and Kai Hormann.
|
||||
This algorithm works only for polygons with real intersection points
|
||||
and line end points on face edges are not considered as such intersection
|
||||
points!
|
||||
|
||||
"""
|
||||
polygon1 = GHPolygon.build(p1)
|
||||
polygon2 = GHPolygon.build(p2)
|
||||
|
||||
if op == BooleanOperation.UNION:
|
||||
return polygon1.union(polygon2)
|
||||
elif op == BooleanOperation.DIFFERENCE:
|
||||
return polygon1.difference(polygon2)
|
||||
elif op == BooleanOperation.INTERSECTION:
|
||||
return polygon1.intersection(polygon2)
|
||||
raise ValueError(f"unknown or unsupported boolean operation: {op}")
|
||||
|
||||
|
||||
LEFT = 0x1
|
||||
RIGHT = 0x2
|
||||
BOTTOM = 0x4
|
||||
TOP = 0x8
|
||||
|
||||
|
||||
class CohenSutherlandLineClipping2d:
|
||||
"""Cohen-Sutherland 2D line clipping algorithm, source:
|
||||
https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
|
||||
|
||||
Args:
|
||||
w_min: bottom-left corner of the clipping rectangle
|
||||
w_max: top-right corner of the clipping rectangle
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("x_min", "x_max", "y_min", "y_max")
|
||||
|
||||
def __init__(self, w_min: Vec2, w_max: Vec2) -> None:
|
||||
self.x_min, self.y_min = w_min
|
||||
self.x_max, self.y_max = w_max
|
||||
|
||||
def encode(self, x: float, y: float) -> int:
|
||||
code: int = 0
|
||||
if x < self.x_min:
|
||||
code |= LEFT
|
||||
elif x > self.x_max:
|
||||
code |= RIGHT
|
||||
if y < self.y_min:
|
||||
code |= BOTTOM
|
||||
elif y > self.y_max:
|
||||
code |= TOP
|
||||
return code
|
||||
|
||||
def clip_line(self, p0: Vec2, p1: Vec2) -> Sequence[Vec2]:
|
||||
"""Returns the clipped line part as tuple[Vec2, Vec2] or an empty tuple.
|
||||
|
||||
Args:
|
||||
p0: start-point of the line to clip
|
||||
p1: end-point of the line to clip
|
||||
|
||||
"""
|
||||
x0, y0 = p0
|
||||
x1, y1 = p1
|
||||
code0 = self.encode(x0, y0)
|
||||
code1 = self.encode(x1, y1)
|
||||
x = x0
|
||||
y = y0
|
||||
while True:
|
||||
if not code0 | code1: # ACCEPT
|
||||
# bitwise OR is 0: both points inside window; trivially accept and
|
||||
# exit loop:
|
||||
return Vec2(x0, y0), Vec2(x1, y1)
|
||||
if code0 & code1: # REJECT
|
||||
# bitwise AND is not 0: both points share an outside zone (LEFT,
|
||||
# RIGHT, TOP, or BOTTOM), so both must be outside window;
|
||||
# exit loop
|
||||
return tuple()
|
||||
|
||||
# failed both tests, so calculate the line segment to clip
|
||||
# from an outside point to an intersection with clip edge
|
||||
# At least one endpoint is outside the clip rectangle; pick it
|
||||
code = code1 if code1 > code0 else code0
|
||||
|
||||
# Now find the intersection point;
|
||||
# use formulas:
|
||||
# slope = (y1 - y0) / (x1 - x0)
|
||||
# x = x0 + (1 / slope) * (ym - y0), where ym is y_min or y_max
|
||||
# y = y0 + slope * (xm - x0), where xm is x_min or x_max
|
||||
# No need to worry about divide-by-zero because, in each case, the
|
||||
# code bit being tested guarantees the denominator is non-zero
|
||||
if code & TOP: # point is above the clip window
|
||||
x = x0 + (x1 - x0) * (self.y_max - y0) / (y1 - y0)
|
||||
y = self.y_max
|
||||
elif code & BOTTOM: # point is below the clip window
|
||||
x = x0 + (x1 - x0) * (self.y_min - y0) / (y1 - y0)
|
||||
y = self.y_min
|
||||
elif code & RIGHT: # point is to the right of clip window
|
||||
y = y0 + (y1 - y0) * (self.x_max - x0) / (x1 - x0)
|
||||
x = self.x_max
|
||||
elif code & LEFT: # point is to the left of clip window
|
||||
y = y0 + (y1 - y0) * (self.x_min - x0) / (x1 - x0)
|
||||
x = self.x_min
|
||||
|
||||
if code == code0:
|
||||
x0 = x
|
||||
y0 = y
|
||||
code0 = self.encode(x0, y0)
|
||||
else:
|
||||
x1 = x
|
||||
y1 = y
|
||||
code1 = self.encode(x1, y1)
|
||||
@@ -0,0 +1,141 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterator, Iterable, Optional
|
||||
import random
|
||||
import statistics
|
||||
import itertools
|
||||
import operator
|
||||
from collections import defaultdict
|
||||
from functools import reduce
|
||||
|
||||
from ezdxf.math import AnyVec, Vec3, spherical_envelope
|
||||
from ezdxf.math.rtree import RTree
|
||||
|
||||
__all__ = [
|
||||
"dbscan",
|
||||
"k_means",
|
||||
"average_cluster_radius",
|
||||
"average_intra_cluster_distance",
|
||||
]
|
||||
|
||||
|
||||
def dbscan(
|
||||
points: list[AnyVec],
|
||||
*,
|
||||
radius: float,
|
||||
min_points: int = 4,
|
||||
rtree: Optional[RTree] = None,
|
||||
max_node_size: int = 5,
|
||||
) -> list[list[AnyVec]]:
|
||||
"""DBSCAN clustering.
|
||||
|
||||
https://en.wikipedia.org/wiki/DBSCAN
|
||||
|
||||
Args:
|
||||
points: list of points to cluster
|
||||
radius: radius of the dense regions
|
||||
min_points: minimum number of points that needs to be within the
|
||||
`radius` for a point to be a core point (must be >= 2)
|
||||
rtree: optional :class:`~ezdxf.math.rtree.RTree`
|
||||
max_node_size: max node size for internally created RTree
|
||||
|
||||
Returns:
|
||||
list of clusters, each cluster is a list of points
|
||||
|
||||
"""
|
||||
if min_points < 2:
|
||||
raise ValueError("min_points must be >= 2")
|
||||
if rtree is None:
|
||||
rtree = RTree(points, max_node_size)
|
||||
|
||||
clusters: list[set[AnyVec]] = []
|
||||
point_set = set(points)
|
||||
while len(point_set):
|
||||
point = point_set.pop()
|
||||
todo = {point}
|
||||
cluster = {point} # the cluster has only a single entry if noise
|
||||
clusters.append(cluster)
|
||||
while len(todo):
|
||||
chk_point = todo.pop()
|
||||
neighbors = set(rtree.points_in_sphere(chk_point, radius))
|
||||
if len(neighbors) < min_points:
|
||||
continue
|
||||
cluster.add(chk_point)
|
||||
point_set.discard(chk_point)
|
||||
todo |= neighbors.intersection(point_set)
|
||||
|
||||
return [list(cluster) for cluster in clusters]
|
||||
|
||||
|
||||
def k_means(
|
||||
points: list[AnyVec], k: int, max_iter: int = 10
|
||||
) -> list[list[AnyVec]]:
|
||||
"""K-means clustering.
|
||||
|
||||
https://en.wikipedia.org/wiki/K-means_clustering
|
||||
|
||||
Args:
|
||||
points: list of points to cluster
|
||||
k: number of clusters
|
||||
max_iter: max iterations
|
||||
|
||||
Returns:
|
||||
list of clusters, each cluster is a list of points
|
||||
|
||||
"""
|
||||
|
||||
def classify(centroids: Iterable[AnyVec]):
|
||||
new_clusters: dict[AnyVec, list[AnyVec]] = defaultdict(list)
|
||||
tree = RTree(centroids)
|
||||
for point in points:
|
||||
nn, _ = tree.nearest_neighbor(point)
|
||||
new_clusters[nn].append(point)
|
||||
return new_clusters
|
||||
|
||||
def recenter() -> Iterator[AnyVec]:
|
||||
for cluster_points in clusters.values():
|
||||
yield Vec3.sum(cluster_points) / len(cluster_points)
|
||||
if len(clusters) < k: # refill centroids if required
|
||||
yield from random.sample(points, k - len(clusters))
|
||||
|
||||
def is_equal_clustering(old_clusters, new_clusters):
|
||||
def hash_list(lst):
|
||||
lst.sort()
|
||||
return reduce(operator.xor, map(hash, lst))
|
||||
|
||||
h1 = sorted(map(hash_list, old_clusters.values()))
|
||||
h2 = sorted(map(hash_list, new_clusters.values()))
|
||||
return h1 == h2
|
||||
|
||||
if not (1 < k < len(points)):
|
||||
raise ValueError(
|
||||
"invalid argument k: must be in range [2, len(points)-1]"
|
||||
)
|
||||
clusters: dict[AnyVec, list[AnyVec]] = classify(random.sample(points, k))
|
||||
for _ in range(max_iter):
|
||||
new_clusters = classify(recenter())
|
||||
if is_equal_clustering(clusters, new_clusters):
|
||||
break
|
||||
clusters = new_clusters
|
||||
return list(clusters.values())
|
||||
|
||||
|
||||
def average_intra_cluster_distance(clusters: list[list[AnyVec]]) -> float:
|
||||
"""Returns the average point-to-point intra cluster distance."""
|
||||
|
||||
return statistics.mean(
|
||||
[
|
||||
p.distance(q)
|
||||
for cluster in clusters
|
||||
for (p, q) in itertools.combinations(cluster, 2)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def average_cluster_radius(clusters: list[list[AnyVec]]) -> float:
|
||||
"""Returns the average cluster radius."""
|
||||
|
||||
return statistics.mean(
|
||||
[spherical_envelope(cluster)[1] for cluster in clusters]
|
||||
)
|
||||
@@ -0,0 +1,380 @@
|
||||
# Copyright (c) 2011-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from functools import partial
|
||||
import math
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from ezdxf.math import (
|
||||
Vec3,
|
||||
Vec2,
|
||||
UVec,
|
||||
Matrix44,
|
||||
X_AXIS,
|
||||
Y_AXIS,
|
||||
arc_angle_span_rad,
|
||||
)
|
||||
|
||||
TOLERANCE = 1e-10
|
||||
RADIANS_90 = math.pi / 2.0
|
||||
RADIANS_180 = math.pi
|
||||
RADIANS_270 = RADIANS_90 * 3.0
|
||||
RADIANS_360 = 2.0 * math.pi
|
||||
|
||||
__all__ = [
|
||||
"closest_point",
|
||||
"convex_hull_2d",
|
||||
"distance_point_line_2d",
|
||||
"is_convex_polygon_2d",
|
||||
"is_axes_aligned_rectangle_2d",
|
||||
"is_point_on_line_2d",
|
||||
"is_point_left_of_line",
|
||||
"point_to_line_relation",
|
||||
"enclosing_angles",
|
||||
"sign",
|
||||
"area",
|
||||
"np_area",
|
||||
"circle_radius_3p",
|
||||
"TOLERANCE",
|
||||
"has_matrix_2d_stretching",
|
||||
"decdeg2dms",
|
||||
"ellipse_param_span",
|
||||
]
|
||||
|
||||
|
||||
def sign(f: float) -> float:
|
||||
"""Return sign of float `f` as -1 or +1, 0 returns +1"""
|
||||
return -1.0 if f < 0.0 else +1.0
|
||||
|
||||
|
||||
def decdeg2dms(value: float) -> tuple[float, float, float]:
|
||||
"""Return decimal degrees as tuple (Degrees, Minutes, Seconds)."""
|
||||
mnt, sec = divmod(value * 3600.0, 60.0)
|
||||
deg, mnt = divmod(mnt, 60.0)
|
||||
return deg, mnt, sec
|
||||
|
||||
|
||||
def ellipse_param_span(start_param: float, end_param: float) -> float:
|
||||
"""Returns the counter-clockwise params span of an elliptic arc from start-
|
||||
to end param.
|
||||
|
||||
Returns the param span in the range [0, 2π], 2π is a full ellipse.
|
||||
Full ellipse handling is a special case, because normalization of params
|
||||
which describe a full ellipse would return 0 if treated as regular params.
|
||||
e.g. (0, 2π) → 2π, (0, -2π) → 2π, (π, -π) → 2π.
|
||||
Input params with the same value always return 0 by definition:
|
||||
(0, 0) → 0, (-π, -π) → 0, (2π, 2π) → 0.
|
||||
|
||||
Alias to function: :func:`ezdxf.math.arc_angle_span_rad`
|
||||
|
||||
"""
|
||||
return arc_angle_span_rad(float(start_param), float(end_param))
|
||||
|
||||
|
||||
def closest_point(base: UVec, points: Iterable[UVec]) -> Vec3 | None:
|
||||
"""Returns the closest point to a give `base` point.
|
||||
|
||||
Args:
|
||||
base: base point as :class:`Vec3` compatible object
|
||||
points: iterable of points as :class:`Vec3` compatible object
|
||||
|
||||
"""
|
||||
base = Vec3(base)
|
||||
min_dist: float | None = None
|
||||
found: Vec3 | None = None
|
||||
for point in points:
|
||||
p = Vec3(point)
|
||||
dist = base.distance(p)
|
||||
if (min_dist is None) or (dist < min_dist):
|
||||
min_dist = dist
|
||||
found = p
|
||||
return found
|
||||
|
||||
|
||||
def convex_hull_2d(points: Iterable[UVec]) -> list[Vec2]:
|
||||
"""Returns the 2D convex hull of given `points`.
|
||||
|
||||
Returns a closed polyline, first vertex is equal to the last vertex.
|
||||
|
||||
Args:
|
||||
points: iterable of points, z-axis is ignored
|
||||
|
||||
"""
|
||||
|
||||
# Source: https://massivealgorithms.blogspot.com/2019/01/convex-hull-sweep-line.html?m=1
|
||||
def cross(o: Vec2, a: Vec2, b: Vec2) -> float:
|
||||
return (a - o).det(b - o)
|
||||
|
||||
vertices = Vec2.list(set(points))
|
||||
vertices.sort()
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("Convex hull calculation requires 3 or more unique points.")
|
||||
|
||||
n: int = len(vertices)
|
||||
hull: list[Vec2] = [Vec2()] * (2 * n)
|
||||
k: int = 0
|
||||
i: int
|
||||
for i in range(n):
|
||||
while k >= 2 and cross(hull[k - 2], hull[k - 1], vertices[i]) <= 0.0:
|
||||
k -= 1
|
||||
hull[k] = vertices[i]
|
||||
k += 1
|
||||
t: int = k + 1
|
||||
for i in range(n - 2, -1, -1):
|
||||
while k >= t and cross(hull[k - 2], hull[k - 1], vertices[i]) <= 0.0:
|
||||
k -= 1
|
||||
hull[k] = vertices[i]
|
||||
k += 1
|
||||
return hull[:k]
|
||||
|
||||
|
||||
def enclosing_angles(angle, start_angle, end_angle, ccw=True, abs_tol=TOLERANCE):
|
||||
isclose = partial(math.isclose, abs_tol=abs_tol)
|
||||
|
||||
s = start_angle % math.tau
|
||||
e = end_angle % math.tau
|
||||
a = angle % math.tau
|
||||
if isclose(s, e):
|
||||
return isclose(s, a)
|
||||
|
||||
if s < e:
|
||||
r = s < a < e
|
||||
else:
|
||||
r = not (e < a < s)
|
||||
return r if ccw else not r
|
||||
|
||||
|
||||
def is_point_on_line_2d(
|
||||
point: Vec2, start: Vec2, end: Vec2, ray=True, abs_tol=TOLERANCE
|
||||
) -> bool:
|
||||
"""Returns ``True`` if `point` is on `line`.
|
||||
|
||||
Args:
|
||||
point: 2D point to test as :class:`Vec2`
|
||||
start: line definition point as :class:`Vec2`
|
||||
end: line definition point as :class:`Vec2`
|
||||
ray: if ``True`` point has to be on the infinite ray, if ``False``
|
||||
point has to be on the line segment
|
||||
abs_tol: tolerance for on the line test
|
||||
|
||||
"""
|
||||
point_x, point_y = point
|
||||
start_x, start_y = start
|
||||
end_x, end_y = end
|
||||
on_line = (
|
||||
math.fabs(
|
||||
(end_y - start_y) * point_x
|
||||
- (end_x - start_x) * point_y
|
||||
+ (end_x * start_y - end_y * start_x)
|
||||
)
|
||||
<= abs_tol
|
||||
)
|
||||
if not on_line or ray:
|
||||
return on_line
|
||||
else:
|
||||
if start_x > end_x:
|
||||
start_x, end_x = end_x, start_x
|
||||
if not (start_x - abs_tol <= point_x <= end_x + abs_tol):
|
||||
return False
|
||||
if start_y > end_y:
|
||||
start_y, end_y = end_y, start_y
|
||||
if not (start_y - abs_tol <= point_y <= end_y + abs_tol):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def point_to_line_relation(
|
||||
point: Vec2, start: Vec2, end: Vec2, abs_tol=TOLERANCE
|
||||
) -> int:
|
||||
"""Returns ``-1`` if `point` is left `line`, ``+1`` if `point` is right of
|
||||
`line` and ``0`` if `point` is on the `line`. The `line` is defined by two
|
||||
vertices given as arguments `start` and `end`.
|
||||
|
||||
Args:
|
||||
point: 2D point to test as :class:`Vec2`
|
||||
start: line definition point as :class:`Vec2`
|
||||
end: line definition point as :class:`Vec2`
|
||||
abs_tol: tolerance for minimum distance to line
|
||||
|
||||
"""
|
||||
rel = (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (
|
||||
point.x - start.x
|
||||
)
|
||||
if abs(rel) <= abs_tol:
|
||||
return 0
|
||||
elif rel < 0:
|
||||
return +1
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
def is_point_left_of_line(point: Vec2, start: Vec2, end: Vec2, colinear=False) -> bool:
|
||||
"""Returns ``True`` if `point` is "left of line" defined by `start-` and
|
||||
`end` point, a colinear point is also "left of line" if argument `colinear`
|
||||
is ``True``.
|
||||
|
||||
Args:
|
||||
point: 2D point to test as :class:`Vec2`
|
||||
start: line definition point as :class:`Vec2`
|
||||
end: line definition point as :class:`Vec2`
|
||||
colinear: a colinear point is also "left of line" if ``True``
|
||||
|
||||
"""
|
||||
rel = point_to_line_relation(point, start, end)
|
||||
if colinear:
|
||||
return rel < 1
|
||||
else:
|
||||
return rel < 0
|
||||
|
||||
|
||||
def distance_point_line_2d(point: Vec2, start: Vec2, end: Vec2) -> float:
|
||||
"""Returns the normal distance from `point` to 2D line defined by `start-`
|
||||
and `end` point.
|
||||
"""
|
||||
# wikipedia: https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line.
|
||||
if start.isclose(end):
|
||||
raise ZeroDivisionError("Not a line.")
|
||||
return math.fabs((start - point).det(end - point)) / (end - start).magnitude
|
||||
|
||||
|
||||
def circle_radius_3p(a: Vec3, b: Vec3, c: Vec3) -> float:
|
||||
ba = b - a
|
||||
ca = c - a
|
||||
cb = c - b
|
||||
upper = ba.magnitude * ca.magnitude * cb.magnitude
|
||||
lower = ba.cross(ca).magnitude * 2.0
|
||||
return upper / lower
|
||||
|
||||
|
||||
def area(vertices: Iterable[UVec]) -> float:
|
||||
"""Returns the area of a polygon.
|
||||
|
||||
Returns the projected area in the xy-plane for any vertices (z-axis will be ignored).
|
||||
|
||||
"""
|
||||
# TODO: how to do all this in numpy efficiently?
|
||||
|
||||
vec2s = Vec2.list(vertices)
|
||||
if len(vec2s) < 3:
|
||||
return 0.0
|
||||
|
||||
# close polygon:
|
||||
if not vec2s[0].isclose(vec2s[-1]):
|
||||
vec2s.append(vec2s[0])
|
||||
return np_area(np.array([(v.x, v.y) for v in vec2s], dtype=np.float64))
|
||||
|
||||
|
||||
def np_area(vertices: npt.NDArray) -> float:
|
||||
"""Returns the area of a polygon.
|
||||
|
||||
Returns the projected area in the xy-plane, the z-axis will be ignored.
|
||||
The polygon has to be closed (first vertex == last vertex) and should have 3 or more
|
||||
corner vertices to return a valid result.
|
||||
|
||||
Args:
|
||||
vertices: numpy array [:, n], n > 1
|
||||
|
||||
"""
|
||||
p1x = vertices[:-1, 0]
|
||||
p2x = vertices[1:, 0]
|
||||
p1y = vertices[:-1, 1]
|
||||
p2y = vertices[1:, 1]
|
||||
return np.abs(np.sum(p1x * p2y - p1y * p2x)) * 0.5
|
||||
|
||||
|
||||
def has_matrix_2d_stretching(m: Matrix44) -> bool:
|
||||
"""Returns ``True`` if matrix `m` performs a non-uniform xy-scaling.
|
||||
Uniform scaling is not stretching in this context.
|
||||
|
||||
Does not check if the target system is a cartesian coordinate system, use the
|
||||
:class:`~ezdxf.math.Matrix44` property :attr:`~ezdxf.math.Matrix44.is_cartesian`
|
||||
for that.
|
||||
"""
|
||||
ux = m.transform_direction(X_AXIS)
|
||||
uy = m.transform_direction(Y_AXIS)
|
||||
return not math.isclose(ux.magnitude_square, uy.magnitude_square)
|
||||
|
||||
|
||||
def is_convex_polygon_2d(polygon: list[Vec2], *, strict=False, epsilon=1e-6) -> bool:
|
||||
"""Returns ``True`` if the 2D `polygon` is convex.
|
||||
|
||||
This function supports open and closed polygons with clockwise or counter-clockwise
|
||||
vertex orientation.
|
||||
|
||||
Coincident vertices will always be skipped and if argument `strict` is ``True``,
|
||||
polygons with collinear vertices are not considered as convex.
|
||||
|
||||
This solution works only for simple non-self-intersecting polygons!
|
||||
|
||||
"""
|
||||
# TODO: Cython implementation
|
||||
if len(polygon) < 3:
|
||||
return False
|
||||
|
||||
global_sign: int = 0
|
||||
current_sign: int = 0
|
||||
prev = polygon[-1]
|
||||
prev_prev = polygon[-2]
|
||||
for vertex in polygon:
|
||||
if vertex.isclose(prev): # skip coincident vertices
|
||||
continue
|
||||
|
||||
det = (prev - vertex).det(prev_prev - prev)
|
||||
if abs(det) >= epsilon:
|
||||
current_sign = -1 if det < 0.0 else +1
|
||||
if not global_sign:
|
||||
global_sign = current_sign
|
||||
# do all determinants have the same sign?
|
||||
if global_sign != current_sign:
|
||||
return False
|
||||
elif strict: # collinear vertices
|
||||
return False
|
||||
|
||||
prev_prev = prev
|
||||
prev = vertex
|
||||
return bool(global_sign)
|
||||
|
||||
|
||||
def is_axes_aligned_rectangle_2d(points: list[Vec2]) -> bool:
|
||||
"""Returns ``True`` if the given points represent a rectangle aligned with the
|
||||
coordinate system axes.
|
||||
|
||||
The sides of the rectangle must be parallel to the x- and y-axes of the coordinate
|
||||
system. The rectangle can be open or closed (first point == last point) and
|
||||
oriented clockwise or counter-clockwise. Only works with 4 or 5 vertices, rectangles
|
||||
that have sides with collinear edges are not considered rectangles.
|
||||
|
||||
.. versionadded:: 1.2.0
|
||||
|
||||
"""
|
||||
|
||||
def is_horizontal(a: Vec2, b: Vec2) -> bool:
|
||||
return math.isclose(a.y, b.y)
|
||||
|
||||
def is_vertical(a: Vec2, b: Vec2):
|
||||
return math.isclose(a.x, b.x)
|
||||
|
||||
count = len(points)
|
||||
if points[0].isclose(points[-1]):
|
||||
count -= 1
|
||||
if count != 4:
|
||||
return False
|
||||
p0, p1, p2, p3, *_ = points
|
||||
if (
|
||||
is_horizontal(p0, p1)
|
||||
and is_vertical(p1, p2)
|
||||
and is_horizontal(p2, p3)
|
||||
and is_vertical(p3, p0)
|
||||
):
|
||||
return True
|
||||
if (
|
||||
is_horizontal(p1, p2)
|
||||
and is_vertical(p2, p3)
|
||||
and is_horizontal(p3, p0)
|
||||
and is_vertical(p0, p1)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,804 @@
|
||||
# Copyright (c) 2020-2025, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Sequence, Iterable, Optional, Iterator
|
||||
from enum import IntEnum
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import (
|
||||
Vec3,
|
||||
Vec2,
|
||||
Matrix44,
|
||||
X_AXIS,
|
||||
Y_AXIS,
|
||||
Z_AXIS,
|
||||
UVec,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"is_planar_face",
|
||||
"subdivide_face",
|
||||
"subdivide_ngons",
|
||||
"Plane",
|
||||
"PlaneLocationState",
|
||||
"normal_vector_3p",
|
||||
"safe_normal_vector",
|
||||
"distance_point_line_3d",
|
||||
"intersection_line_line_3d",
|
||||
"intersection_ray_polygon_3d",
|
||||
"intersection_line_polygon_3d",
|
||||
"basic_transformation",
|
||||
"best_fit_normal",
|
||||
"BarycentricCoordinates",
|
||||
"linear_vertex_spacing",
|
||||
"has_matrix_3d_stretching",
|
||||
"spherical_envelope",
|
||||
"inscribe_circle_tangent_length",
|
||||
"bending_angle",
|
||||
"split_polygon_by_plane",
|
||||
"is_face_normal_pointing_outwards",
|
||||
"is_vertex_order_ccw_3d",
|
||||
]
|
||||
PI2 = math.pi / 2.0
|
||||
|
||||
|
||||
def is_planar_face(face: Sequence[Vec3], abs_tol=1e-9) -> bool:
|
||||
"""Returns ``True`` if sequence of vectors is a planar face.
|
||||
|
||||
Args:
|
||||
face: sequence of :class:`~ezdxf.math.Vec3` objects
|
||||
abs_tol: tolerance for normals check
|
||||
|
||||
"""
|
||||
if len(face) < 3:
|
||||
return False
|
||||
if len(face) == 3:
|
||||
return True
|
||||
first_normal = None
|
||||
for index in range(len(face) - 2):
|
||||
a, b, c = face[index : index + 3]
|
||||
try:
|
||||
normal = (b - a).cross(c - b).normalize()
|
||||
except ZeroDivisionError: # colinear edge
|
||||
continue
|
||||
if first_normal is None:
|
||||
first_normal = normal
|
||||
elif not first_normal.isclose(normal, abs_tol=abs_tol):
|
||||
return False
|
||||
if first_normal is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def subdivide_face(
|
||||
face: Sequence[Vec3], quads: bool = True
|
||||
) -> Iterator[Sequence[Vec3]]:
|
||||
"""Subdivides faces by subdividing edges and adding a center vertex.
|
||||
|
||||
Args:
|
||||
face: a sequence of :class:`Vec3`
|
||||
quads: create quad faces if ``True`` else create triangles
|
||||
|
||||
"""
|
||||
if len(face) < 3:
|
||||
raise ValueError("3 or more vertices required.")
|
||||
|
||||
len_face: int = len(face)
|
||||
mid_pos = Vec3.sum(face) / len_face
|
||||
subdiv_location: list[Vec3] = [
|
||||
face[i].lerp(face[(i + 1) % len_face]) for i in range(len_face)
|
||||
]
|
||||
|
||||
for index, vertex in enumerate(face):
|
||||
if quads:
|
||||
yield vertex, subdiv_location[index], mid_pos, subdiv_location[index - 1]
|
||||
else:
|
||||
yield subdiv_location[index - 1], vertex, mid_pos
|
||||
yield vertex, subdiv_location[index], mid_pos
|
||||
|
||||
|
||||
def subdivide_ngons(
|
||||
faces: Iterable[Sequence[Vec3]],
|
||||
max_vertex_count=4,
|
||||
) -> Iterator[Sequence[Vec3]]:
|
||||
"""Subdivides faces into triangles by adding a center vertex.
|
||||
|
||||
Args:
|
||||
faces: iterable of faces as sequence of :class:`Vec3`
|
||||
max_vertex_count: subdivide only ngons with more vertices
|
||||
|
||||
"""
|
||||
for face in faces:
|
||||
if len(face) <= max_vertex_count:
|
||||
yield Vec3.tuple(face)
|
||||
else:
|
||||
mid_pos = Vec3.sum(face) / len(face)
|
||||
for index, vertex in enumerate(face):
|
||||
yield face[index - 1], vertex, mid_pos
|
||||
|
||||
|
||||
def normal_vector_3p(a: Vec3, b: Vec3, c: Vec3) -> Vec3:
|
||||
"""Returns normal vector for 3 points, which is the normalized cross
|
||||
product for: :code:`a->b x a->c`.
|
||||
"""
|
||||
return (b - a).cross(c - a).normalize()
|
||||
|
||||
|
||||
def safe_normal_vector(vertices: Sequence[Vec3]) -> Vec3:
|
||||
"""Safe function to detect the normal vector for a face or polygon defined
|
||||
by 3 or more `vertices`.
|
||||
|
||||
"""
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("3 or more vertices required")
|
||||
a, b, c, *_ = vertices
|
||||
try: # fast path
|
||||
return (b - a).cross(c - a).normalize()
|
||||
except ZeroDivisionError: # safe path, can still raise ZeroDivisionError
|
||||
return best_fit_normal(vertices)
|
||||
|
||||
|
||||
def best_fit_normal(vertices: Iterable[UVec]) -> Vec3:
|
||||
"""Returns the "best fit" normal for a plane defined by three or more
|
||||
vertices. This function tolerates imperfect plane vertices. Safe function
|
||||
to detect the extrusion vector of flat arbitrary polygons.
|
||||
|
||||
"""
|
||||
# Source: https://gamemath.com/book/geomprims.html#plane_best_fit (9.5.3)
|
||||
_vertices = Vec3.list(vertices)
|
||||
if len(_vertices) < 3:
|
||||
raise ValueError("3 or more vertices required")
|
||||
first = _vertices[0]
|
||||
if not first.isclose(_vertices[-1]):
|
||||
_vertices.append(first) # close polygon
|
||||
prev_x, prev_y, prev_z = first.xyz
|
||||
nx = 0.0
|
||||
ny = 0.0
|
||||
nz = 0.0
|
||||
for v in _vertices[1:]:
|
||||
x, y, z = v.xyz
|
||||
nx += (prev_z + z) * (prev_y - y)
|
||||
ny += (prev_x + x) * (prev_z - z)
|
||||
nz += (prev_y + y) * (prev_x - x)
|
||||
prev_x = x
|
||||
prev_y = y
|
||||
prev_z = z
|
||||
return Vec3(nx, ny, nz).normalize()
|
||||
|
||||
|
||||
def distance_point_line_3d(point: Vec3, start: Vec3, end: Vec3) -> float:
|
||||
"""Returns the normal distance from a `point` to a 3D line.
|
||||
|
||||
Args:
|
||||
point: point to test
|
||||
start: start point of the 3D line
|
||||
end: end point of the 3D line
|
||||
|
||||
"""
|
||||
if start.isclose(end):
|
||||
raise ZeroDivisionError("Not a line.")
|
||||
v1 = point - start
|
||||
# point projected onto line start to end:
|
||||
v2 = (end - start).project(v1)
|
||||
# Pythagoras:
|
||||
diff = v1.magnitude_square - v2.magnitude_square
|
||||
if diff <= 0.0:
|
||||
# This should not happen (abs(v1) > abs(v2)), but floating point
|
||||
# imprecision at very small values makes it possible!
|
||||
return 0.0
|
||||
else:
|
||||
return math.sqrt(diff)
|
||||
|
||||
|
||||
def intersection_line_line_3d(
|
||||
line1: Sequence[Vec3],
|
||||
line2: Sequence[Vec3],
|
||||
virtual: bool = True,
|
||||
abs_tol: float = 1e-10,
|
||||
) -> Optional[Vec3]:
|
||||
"""
|
||||
Returns the intersection point of two 3D lines, returns ``None`` if lines
|
||||
do not intersect.
|
||||
|
||||
Args:
|
||||
line1: first line as tuple of two points as :class:`Vec3` objects
|
||||
line2: second line as tuple of two points as :class:`Vec3` objects
|
||||
virtual: ``True`` returns any intersection point, ``False`` returns only
|
||||
real intersection points
|
||||
abs_tol: absolute tolerance for comparisons
|
||||
|
||||
"""
|
||||
from ezdxf.math import intersection_ray_ray_3d, BoundingBox
|
||||
|
||||
res = intersection_ray_ray_3d(line1, line2, abs_tol)
|
||||
if len(res) != 1:
|
||||
return None
|
||||
|
||||
point = res[0]
|
||||
if virtual:
|
||||
return point
|
||||
if BoundingBox(line1).inside(point) and BoundingBox(line2).inside(point):
|
||||
return point
|
||||
return None
|
||||
|
||||
|
||||
def basic_transformation(
|
||||
move: UVec = (0, 0, 0),
|
||||
scale: UVec = (1, 1, 1),
|
||||
z_rotation: float = 0,
|
||||
) -> Matrix44:
|
||||
"""Returns a combined transformation matrix for translation, scaling and
|
||||
rotation about the z-axis.
|
||||
|
||||
Args:
|
||||
move: translation vector
|
||||
scale: x-, y- and z-axis scaling as float triplet, e.g. (2, 2, 1)
|
||||
z_rotation: rotation angle about the z-axis in radians
|
||||
|
||||
"""
|
||||
sx, sy, sz = Vec3(scale)
|
||||
m = Matrix44.scale(sx, sy, sz)
|
||||
if z_rotation:
|
||||
m *= Matrix44.z_rotate(z_rotation)
|
||||
translate = Vec3(move)
|
||||
if not translate.is_null:
|
||||
m *= Matrix44.translate(translate.x, translate.y, translate.z)
|
||||
return m
|
||||
|
||||
|
||||
PLANE_EPSILON = 1e-9
|
||||
|
||||
|
||||
class PlaneLocationState(IntEnum):
|
||||
COPLANAR = 0 # all the vertices are within the plane
|
||||
FRONT = 1 # all the vertices are in front of the plane
|
||||
BACK = 2 # all the vertices are at the back of the plane
|
||||
SPANNING = 3 # some vertices are in front, some in the back
|
||||
|
||||
|
||||
class Plane:
|
||||
"""Construction tool for 3D planes.
|
||||
|
||||
Represents a plane in 3D space as a normal vector and the perpendicular
|
||||
distance from the origin.
|
||||
"""
|
||||
|
||||
__slots__ = ("_normal", "_distance_from_origin")
|
||||
|
||||
def __init__(self, normal: Vec3, distance: float):
|
||||
assert normal.is_null is False, "invalid plane normal"
|
||||
self._normal = normal
|
||||
# the (perpendicular) distance of the plane from (0, 0, 0)
|
||||
self._distance_from_origin = distance
|
||||
|
||||
@property
|
||||
def normal(self) -> Vec3:
|
||||
"""Normal vector of the plane."""
|
||||
return self._normal
|
||||
|
||||
@property
|
||||
def distance_from_origin(self) -> float:
|
||||
"""The (perpendicular) distance of the plane from origin (0, 0, 0)."""
|
||||
return self._distance_from_origin
|
||||
|
||||
@property
|
||||
def vector(self) -> Vec3:
|
||||
"""Returns the location vector."""
|
||||
return self._normal * self._distance_from_origin
|
||||
|
||||
@classmethod
|
||||
def from_3p(cls, a: Vec3, b: Vec3, c: Vec3) -> "Plane":
|
||||
"""Returns a new plane from 3 points in space."""
|
||||
try:
|
||||
n = (b - a).cross(c - a).normalize()
|
||||
except ZeroDivisionError:
|
||||
raise ValueError("undefined plane: colinear vertices")
|
||||
return Plane(n, n.dot(a))
|
||||
|
||||
@classmethod
|
||||
def from_vector(cls, vector: UVec) -> "Plane":
|
||||
"""Returns a new plane from the given location vector."""
|
||||
v = Vec3(vector)
|
||||
try:
|
||||
return Plane(v.normalize(), v.magnitude)
|
||||
except ZeroDivisionError:
|
||||
raise ValueError("invalid NULL vector")
|
||||
|
||||
def __copy__(self) -> "Plane":
|
||||
"""Returns a copy of the plane."""
|
||||
return self.__class__(self._normal, self._distance_from_origin)
|
||||
|
||||
copy = __copy__
|
||||
|
||||
def __repr__(self):
|
||||
return f"Plane({repr(self._normal)}, {self._distance_from_origin})"
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Plane):
|
||||
return NotImplemented
|
||||
return self.vector == other.vector
|
||||
|
||||
def signed_distance_to(self, v: Vec3) -> float:
|
||||
"""Returns signed distance of vertex `v` to plane, if distance is > 0,
|
||||
`v` is in 'front' of plane, in direction of the normal vector, if
|
||||
distance is < 0, `v` is at the 'back' of the plane, in the opposite
|
||||
direction of the normal vector.
|
||||
|
||||
"""
|
||||
return self._normal.dot(v) - self._distance_from_origin
|
||||
|
||||
def distance_to(self, v: Vec3) -> float:
|
||||
"""Returns absolute (unsigned) distance of vertex `v` to plane."""
|
||||
return math.fabs(self.signed_distance_to(v))
|
||||
|
||||
def is_coplanar_vertex(self, v: Vec3, abs_tol=1e-9) -> bool:
|
||||
"""Returns ``True`` if vertex `v` is coplanar, distance from plane to
|
||||
vertex `v` is 0.
|
||||
"""
|
||||
return self.distance_to(v) < abs_tol
|
||||
|
||||
def is_coplanar_plane(self, p: "Plane", abs_tol=1e-9) -> bool:
|
||||
"""Returns ``True`` if plane `p` is coplanar, normal vectors in same or
|
||||
opposite direction.
|
||||
"""
|
||||
n_is_close = self._normal.isclose
|
||||
return n_is_close(p._normal, abs_tol=abs_tol) or n_is_close(
|
||||
-p._normal, abs_tol=abs_tol
|
||||
)
|
||||
|
||||
def intersect_line(
|
||||
self, start: Vec3, end: Vec3, *, coplanar=True, abs_tol=PLANE_EPSILON
|
||||
) -> Optional[Vec3]:
|
||||
"""Returns the intersection point of the 3D line from `start` to `end`
|
||||
and this plane or ``None`` if there is no intersection. If the argument
|
||||
`coplanar` is ``False`` the start- or end point of the line are ignored
|
||||
as intersection points.
|
||||
|
||||
"""
|
||||
state0 = self.vertex_location_state(start, abs_tol)
|
||||
state1 = self.vertex_location_state(end, abs_tol)
|
||||
if state0 is state1:
|
||||
return None
|
||||
if not coplanar and (
|
||||
state0 is PlaneLocationState.COPLANAR
|
||||
or state1 is PlaneLocationState.COPLANAR
|
||||
):
|
||||
return None
|
||||
n = self.normal
|
||||
weight = (self.distance_from_origin - n.dot(start)) / n.dot(end - start)
|
||||
return start.lerp(end, weight)
|
||||
|
||||
def intersect_ray(self, origin: Vec3, direction: Vec3) -> Optional[Vec3]:
|
||||
"""Returns the intersection point of the infinite 3D ray defined by
|
||||
`origin` and the `direction` vector and this plane or ``None`` if there
|
||||
is no intersection. A coplanar ray does not intersect the plane!
|
||||
|
||||
"""
|
||||
n = self.normal
|
||||
try:
|
||||
weight = (self.distance_from_origin - n.dot(origin)) / n.dot(direction)
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
return origin + (direction * weight)
|
||||
|
||||
def vertex_location_state(
|
||||
self, vertex: Vec3, abs_tol=PLANE_EPSILON
|
||||
) -> PlaneLocationState:
|
||||
"""Returns the :class:`PlaneLocationState` of the given `vertex` in
|
||||
relative to this plane.
|
||||
|
||||
"""
|
||||
distance = self._normal.dot(vertex) - self._distance_from_origin
|
||||
if distance < -abs_tol:
|
||||
return PlaneLocationState.BACK
|
||||
elif distance > abs_tol:
|
||||
return PlaneLocationState.FRONT
|
||||
else:
|
||||
return PlaneLocationState.COPLANAR
|
||||
|
||||
|
||||
def split_polygon_by_plane(
|
||||
polygon: Iterable[Vec3],
|
||||
plane: Plane,
|
||||
*,
|
||||
coplanar=True,
|
||||
abs_tol=PLANE_EPSILON,
|
||||
) -> tuple[Sequence[Vec3], Sequence[Vec3]]:
|
||||
"""Split a convex `polygon` by the given `plane`.
|
||||
|
||||
Returns a tuple of front- and back vertices (front, back).
|
||||
Returns also coplanar polygons if the
|
||||
argument `coplanar` is ``True``, the coplanar vertices goes into either
|
||||
front or back depending on their orientation with respect to this plane.
|
||||
|
||||
"""
|
||||
polygon_type = PlaneLocationState.COPLANAR
|
||||
vertex_types: list[PlaneLocationState] = []
|
||||
front_vertices: list[Vec3] = []
|
||||
back_vertices: list[Vec3] = []
|
||||
vertices = list(polygon)
|
||||
w = plane.distance_from_origin
|
||||
normal = plane.normal
|
||||
|
||||
# Classify each point as well as the entire polygon into one of four classes:
|
||||
# COPLANAR, FRONT, BACK, SPANNING = FRONT + BACK
|
||||
for vertex in vertices:
|
||||
vertex_type = plane.vertex_location_state(vertex, abs_tol)
|
||||
polygon_type |= vertex_type # type: ignore
|
||||
vertex_types.append(vertex_type)
|
||||
|
||||
# Put the polygon in the correct list, splitting it when necessary.
|
||||
if polygon_type == PlaneLocationState.COPLANAR:
|
||||
if coplanar:
|
||||
polygon_normal = best_fit_normal(vertices)
|
||||
if normal.dot(polygon_normal) > 0:
|
||||
front_vertices = vertices
|
||||
else:
|
||||
back_vertices = vertices
|
||||
elif polygon_type == PlaneLocationState.FRONT:
|
||||
front_vertices = vertices
|
||||
elif polygon_type == PlaneLocationState.BACK:
|
||||
back_vertices = vertices
|
||||
elif polygon_type == PlaneLocationState.SPANNING:
|
||||
len_vertices = len(vertices)
|
||||
for index in range(len_vertices):
|
||||
next_index = (index + 1) % len_vertices
|
||||
vertex_type = vertex_types[index]
|
||||
next_vertex_type = vertex_types[next_index]
|
||||
vertex = vertices[index]
|
||||
next_vertex = vertices[next_index]
|
||||
if vertex_type != PlaneLocationState.BACK: # FRONT or COPLANAR
|
||||
front_vertices.append(vertex)
|
||||
if vertex_type != PlaneLocationState.FRONT: # BACK or COPLANAR
|
||||
back_vertices.append(vertex)
|
||||
if (vertex_type | next_vertex_type) == PlaneLocationState.SPANNING:
|
||||
interpolation_weight = (w - normal.dot(vertex)) / normal.dot(
|
||||
next_vertex - vertex
|
||||
)
|
||||
plane_intersection_point = vertex.lerp(
|
||||
next_vertex, interpolation_weight
|
||||
)
|
||||
front_vertices.append(plane_intersection_point)
|
||||
back_vertices.append(plane_intersection_point)
|
||||
if len(front_vertices) < 3:
|
||||
front_vertices = []
|
||||
if len(back_vertices) < 3:
|
||||
back_vertices = []
|
||||
return tuple(front_vertices), tuple(back_vertices)
|
||||
|
||||
|
||||
def intersection_line_polygon_3d(
|
||||
start: Vec3,
|
||||
end: Vec3,
|
||||
polygon: Iterable[Vec3],
|
||||
*,
|
||||
coplanar=True,
|
||||
boundary=True,
|
||||
abs_tol=PLANE_EPSILON,
|
||||
) -> Optional[Vec3]:
|
||||
"""Returns the intersection point of the 3D line form `start` to `end` and
|
||||
the given `polygon`.
|
||||
|
||||
Args:
|
||||
start: start point of 3D line as :class:`Vec3`
|
||||
end: end point of 3D line as :class:`Vec3`
|
||||
polygon: 3D polygon as iterable of :class:`Vec3`
|
||||
coplanar: if ``True`` a coplanar start- or end point as intersection
|
||||
point is valid
|
||||
boundary: if ``True`` an intersection point at the polygon boundary line
|
||||
is valid
|
||||
abs_tol: absolute tolerance for comparisons
|
||||
|
||||
"""
|
||||
vertices = list(polygon)
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("3 or more vertices required")
|
||||
try:
|
||||
normal = safe_normal_vector(vertices)
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
plane = Plane(normal, normal.dot(vertices[0]))
|
||||
ip = plane.intersect_line(start, end, coplanar=coplanar, abs_tol=abs_tol)
|
||||
if ip is None:
|
||||
return None
|
||||
return _is_intersection_point_inside_3d_polygon(
|
||||
ip, vertices, normal, boundary, abs_tol
|
||||
)
|
||||
|
||||
|
||||
def intersection_ray_polygon_3d(
|
||||
origin: Vec3,
|
||||
direction: Vec3,
|
||||
polygon: Iterable[Vec3],
|
||||
*,
|
||||
boundary=True,
|
||||
abs_tol=PLANE_EPSILON,
|
||||
) -> Optional[Vec3]:
|
||||
"""Returns the intersection point of the infinite 3D ray defined by `origin`
|
||||
and the `direction` vector and the given `polygon`.
|
||||
|
||||
Args:
|
||||
origin: origin point of the 3D ray as :class:`Vec3`
|
||||
direction: direction vector of the 3D ray as :class:`Vec3`
|
||||
polygon: 3D polygon as iterable of :class:`Vec3`
|
||||
boundary: if ``True`` intersection points at the polygon boundary line
|
||||
are valid
|
||||
abs_tol: absolute tolerance for comparisons
|
||||
|
||||
"""
|
||||
|
||||
vertices = list(polygon)
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("3 or more vertices required")
|
||||
try:
|
||||
normal = safe_normal_vector(vertices)
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
plane = Plane(normal, normal.dot(vertices[0]))
|
||||
ip = plane.intersect_ray(origin, direction)
|
||||
if ip is None:
|
||||
return None
|
||||
return _is_intersection_point_inside_3d_polygon(
|
||||
ip, vertices, normal, boundary, abs_tol
|
||||
)
|
||||
|
||||
|
||||
def _is_intersection_point_inside_3d_polygon(
|
||||
ip: Vec3, vertices: list[Vec3], normal: Vec3, boundary: bool, abs_tol: float
|
||||
):
|
||||
from ezdxf.math import is_point_in_polygon_2d, OCS
|
||||
|
||||
ocs = OCS(normal)
|
||||
ocs_vertices = Vec2.list(ocs.points_from_wcs(vertices))
|
||||
state = is_point_in_polygon_2d(
|
||||
Vec2(ocs.from_wcs(ip)), ocs_vertices, abs_tol=abs_tol
|
||||
)
|
||||
if state > 0 or (boundary and state == 0):
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
class BarycentricCoordinates:
|
||||
"""Barycentric coordinate calculation.
|
||||
|
||||
The arguments `a`, `b` and `c` are the cartesian coordinates of an arbitrary
|
||||
triangle in 3D space. The barycentric coordinates (b1, b2, b3) define the
|
||||
linear combination of `a`, `b` and `c` to represent the point `p`::
|
||||
|
||||
p = a * b1 + b * b2 + c * b3
|
||||
|
||||
This implementation returns the barycentric coordinates of the normal
|
||||
projection of `p` onto the plane defined by (a, b, c).
|
||||
|
||||
These barycentric coordinates have some useful properties:
|
||||
|
||||
- if all barycentric coordinates (b1, b2, b3) are in the range [0, 1], then
|
||||
the point `p` is inside the triangle (a, b, c)
|
||||
- if one of the coordinates is negative, the point `p` is outside the
|
||||
triangle
|
||||
- the sum of b1, b2 and b3 is always 1
|
||||
- the center of "mass" has the barycentric coordinates (1/3, 1/3, 1/3) =
|
||||
(a + b + c)/3
|
||||
|
||||
"""
|
||||
|
||||
# Source: https://gamemath.com/book/geomprims.html#triangle_barycentric_space
|
||||
|
||||
def __init__(self, a: UVec, b: UVec, c: UVec):
|
||||
self.a = Vec3(a)
|
||||
self.b = Vec3(b)
|
||||
self.c = Vec3(c)
|
||||
self._e1 = self.c - self.b
|
||||
self._e2 = self.a - self.c
|
||||
self._e3 = self.b - self.a
|
||||
e1xe2 = self._e1.cross(self._e2)
|
||||
self._n = e1xe2.normalize()
|
||||
self._denom = e1xe2.dot(self._n)
|
||||
if abs(self._denom) < 1e-9:
|
||||
raise ValueError("invalid triangle")
|
||||
|
||||
def from_cartesian(self, p: UVec) -> Vec3:
|
||||
p = Vec3(p)
|
||||
n = self._n
|
||||
denom = self._denom
|
||||
d1 = p - self.a
|
||||
d2 = p - self.b
|
||||
d3 = p - self.c
|
||||
b1 = self._e1.cross(d3).dot(n) / denom
|
||||
b2 = self._e2.cross(d1).dot(n) / denom
|
||||
b3 = self._e3.cross(d2).dot(n) / denom
|
||||
return Vec3(b1, b2, b3)
|
||||
|
||||
def to_cartesian(self, b: UVec) -> Vec3:
|
||||
b1, b2, b3 = Vec3(b).xyz
|
||||
return self.a * b1 + self.b * b2 + self.c * b3
|
||||
|
||||
|
||||
def linear_vertex_spacing(start: Vec3, end: Vec3, count: int) -> list[Vec3]:
|
||||
"""Returns `count` evenly spaced vertices from `start` to `end`."""
|
||||
if count <= 2:
|
||||
return [start, end]
|
||||
distance = end - start
|
||||
if distance.is_null:
|
||||
return [start] * count
|
||||
|
||||
vertices = [start]
|
||||
step = distance.normalize(distance.magnitude / (count - 1))
|
||||
for _ in range(1, count - 1):
|
||||
start += step
|
||||
vertices.append(start)
|
||||
vertices.append(end)
|
||||
return vertices
|
||||
|
||||
|
||||
def has_matrix_3d_stretching(m: Matrix44) -> bool:
|
||||
"""Returns ``True`` if matrix `m` performs a non-uniform xyz-scaling.
|
||||
Uniform scaling is not stretching in this context.
|
||||
|
||||
Does not check if the target system is a cartesian coordinate system, use the
|
||||
:class:`~ezdxf.math.Matrix44` property :attr:`~ezdxf.math.Matrix44.is_cartesian`
|
||||
for that.
|
||||
"""
|
||||
ux_mag_sqr = m.transform_direction(X_AXIS).magnitude_square
|
||||
uy = m.transform_direction(Y_AXIS)
|
||||
uz = m.transform_direction(Z_AXIS)
|
||||
return not math.isclose(ux_mag_sqr, uy.magnitude_square) or not math.isclose(
|
||||
ux_mag_sqr, uz.magnitude_square
|
||||
)
|
||||
|
||||
|
||||
def spherical_envelope(points: Sequence[UVec]) -> tuple[Vec3, float]:
|
||||
"""Calculate the spherical envelope for the given points. Returns the
|
||||
centroid (a.k.a. geometric center) and the radius of the enclosing sphere.
|
||||
|
||||
.. note::
|
||||
|
||||
The result does not represent the minimal bounding sphere!
|
||||
|
||||
"""
|
||||
centroid = Vec3.sum(points) / len(points)
|
||||
radius = max(centroid.distance(p) for p in points)
|
||||
return centroid, radius
|
||||
|
||||
|
||||
def inscribe_circle_tangent_length(dir1: Vec3, dir2: Vec3, radius: float) -> float:
|
||||
"""Returns the tangent length of an inscribe-circle of the given `radius`.
|
||||
The direction `dir1` and `dir2` define two intersection tangents,
|
||||
The tangent length is the distance from the intersection point of the
|
||||
tangents to the touching point on the inscribe-circle.
|
||||
|
||||
"""
|
||||
alpha = dir1.angle_between(dir2)
|
||||
beta = PI2 - (alpha / 2.0)
|
||||
if math.isclose(abs(beta), PI2):
|
||||
return 0.0
|
||||
return abs(math.tan(beta) * radius)
|
||||
|
||||
|
||||
def bending_angle(dir1: Vec3, dir2: Vec3, normal=Z_AXIS) -> float:
|
||||
"""Returns the bending angle from `dir1` to `dir2` in radians.
|
||||
|
||||
The normal vector is required to detect the bending orientation,
|
||||
an angle > 0 bends to the "left" an angle < 0 bends to the "right".
|
||||
|
||||
"""
|
||||
angle = dir1.angle_between(dir2)
|
||||
nn = dir1.cross(dir2)
|
||||
if nn.isclose(normal) or nn.is_null:
|
||||
return angle
|
||||
elif nn.isclose(-normal):
|
||||
return -angle
|
||||
raise ValueError("invalid normal vector")
|
||||
|
||||
|
||||
def any_vertex_inside_face(vertices: Sequence[Vec3]) -> Vec3:
|
||||
"""Returns a vertex from the "inside" of the given face."""
|
||||
# Triangulation is for concave shapes important!
|
||||
from ezdxf.math.triangulation import mapbox_earcut_3d
|
||||
|
||||
it = mapbox_earcut_3d(vertices)
|
||||
return Vec3.sum(next(it)) / 3.0
|
||||
|
||||
|
||||
def front_faces_intersect_face_normal(
|
||||
faces: Sequence[Sequence[Vec3]],
|
||||
face: Sequence[Vec3],
|
||||
*,
|
||||
abs_tol=PLANE_EPSILON,
|
||||
) -> int:
|
||||
"""Returns the count of intersections of the normal-vector of the given
|
||||
`face` with the `faces` in front of this `face`.
|
||||
|
||||
A counter-clockwise vertex order is assumed!
|
||||
|
||||
"""
|
||||
|
||||
def is_face_in_front_of_detector(vertices: Sequence[Vec3]) -> bool:
|
||||
if len(vertices) < 3:
|
||||
return False
|
||||
return any(detector_plane.signed_distance_to(v) > abs_tol for v in vertices)
|
||||
|
||||
# face-normal for counter-clockwise vertex order
|
||||
face_normal = safe_normal_vector(face)
|
||||
origin = any_vertex_inside_face(face)
|
||||
detector_plane = Plane(face_normal, face_normal.dot(origin))
|
||||
|
||||
# collect all faces with at least one vertex in front of the detection plane
|
||||
front_faces = (f for f in faces if is_face_in_front_of_detector(f))
|
||||
|
||||
# The detector face is excluded by the
|
||||
# is_face_in_front_of_detector() function!
|
||||
|
||||
intersection_points: set[Vec3] = set()
|
||||
for face in front_faces:
|
||||
ip = intersection_ray_polygon_3d(
|
||||
origin, face_normal, face, boundary=True, abs_tol=abs_tol
|
||||
)
|
||||
if ip is None:
|
||||
continue
|
||||
if detector_plane.signed_distance_to(ip) > abs_tol:
|
||||
# Only count unique intersections points, the ip could lie on an
|
||||
# edge (2 ips) or even a corner vertex (3 or more ips).
|
||||
intersection_points.add(ip.round(6))
|
||||
return len(intersection_points)
|
||||
|
||||
|
||||
def is_face_normal_pointing_outwards(
|
||||
faces: Sequence[Sequence[Vec3]],
|
||||
face: Sequence[Vec3],
|
||||
*,
|
||||
abs_tol=PLANE_EPSILON,
|
||||
) -> bool:
|
||||
"""Returns ``True`` if the face-normal for the given `face` of a
|
||||
closed surface is pointing outwards. A counter-clockwise vertex order is
|
||||
assumed, for faces with clockwise vertex order the result is inverted,
|
||||
therefore ``False`` is pointing outwards.
|
||||
|
||||
This function does not check if the `faces` are a closed surface.
|
||||
|
||||
"""
|
||||
return front_faces_intersect_face_normal(faces, face, abs_tol=abs_tol) % 2 == 0
|
||||
|
||||
|
||||
def is_vertex_order_ccw_3d(vertices: list[Vec3], normal: Vec3) -> bool:
|
||||
"""Returns ``True`` when the given 3D vertices have a counter-clockwise order around
|
||||
the given normal vector.
|
||||
|
||||
Works for convex and concave shapes. Does not check or care if all vertices are
|
||||
located in a flat plane or if the normal vector is really perpendicular to the
|
||||
shape, but the result may be incorrect in that cases.
|
||||
|
||||
Args:
|
||||
vertices (list): corner vertices of a flat shape (polygon)
|
||||
normal (Vec3): normal vector of the shape
|
||||
|
||||
Raises:
|
||||
ValueError: input has less than 3 vertices
|
||||
|
||||
"""
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("3 or more vertices required")
|
||||
|
||||
def signed_area() -> float:
|
||||
# using the shoelace formula.
|
||||
polygon = np.array(vertices)
|
||||
if dom == 0: # dominant X, use YZ plane
|
||||
x, y = polygon[:, 1], polygon[:, 2]
|
||||
elif dom == 1: # dominant Y, use XZ plane
|
||||
x, y = polygon[:, 0], polygon[:, 2]
|
||||
else: # dominant Z, use XY plane
|
||||
x, y = polygon[:, 0], polygon[:, 1]
|
||||
# returns twice the area, but only the sign is relevant for this use case
|
||||
return np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))
|
||||
|
||||
# The polygon is maybe concave, direct cross-product checks between
|
||||
# adjacent edges are unreliable. Instead, use a projected signed area method.
|
||||
# Find dominant axis:
|
||||
abs_axis = abs(normal.x), abs(normal.y), abs(normal.z)
|
||||
dom = abs_axis.index(max(abs_axis))
|
||||
|
||||
ccw = signed_area() > 0
|
||||
dom_axis = normal[dom]
|
||||
return dom_axis > 0 if ccw else dom_axis < 0
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2020-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import Vec3
|
||||
|
||||
|
||||
class CSpline:
|
||||
"""
|
||||
In numerical analysis, a cubic Hermite spline or cubic Hermite interpolator
|
||||
is a spline where each piece is a third-degree polynomial specified in
|
||||
Hermite form, that is, by its values and first derivatives at the end points
|
||||
of the corresponding domain interval.
|
||||
|
||||
Source: https://en.wikipedia.org/wiki/Cubic_Hermite_spline
|
||||
"""
|
||||
|
||||
# https://de.wikipedia.org/wiki/Kubisch_Hermitescher_Spline
|
||||
def __init__(self, p0: Vec3, p1: Vec3, m0: Vec3, m1: Vec3):
|
||||
self.p0 = p0
|
||||
self.p1 = p1
|
||||
self.m0 = m0
|
||||
self.m1 = m1
|
||||
|
||||
def point(self, t: float) -> Vec3:
|
||||
t2 = t * t
|
||||
t3 = t2 * t
|
||||
h00 = t3 * 2.0 - t2 * 3.0 + 1.0
|
||||
h10 = -t3 * 2.0 + t2 * 3.0
|
||||
h01 = t3 - t2 * 2.0 + t
|
||||
h11 = t3 - t2
|
||||
return self.p0 * h00 + self.p1 * h10 + self.m0 * h01 + self.m1 * h11
|
||||
|
||||
|
||||
def approximate(csplines: Iterable[CSpline], count) -> Iterable[Vec3]:
|
||||
for cspline in csplines:
|
||||
for t in np.linspace(0.0, 1.0, count):
|
||||
yield cspline.point(t)
|
||||
@@ -0,0 +1,286 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Union, Sequence, TypeVar
|
||||
import math
|
||||
from ezdxf.math import (
|
||||
BSpline,
|
||||
Bezier4P,
|
||||
Bezier3P,
|
||||
UVec,
|
||||
Vec3,
|
||||
Vec2,
|
||||
AnyVec,
|
||||
BoundingBox,
|
||||
)
|
||||
from ezdxf.math.linalg import cubic_equation
|
||||
|
||||
__all__ = [
|
||||
"bezier_to_bspline",
|
||||
"quadratic_to_cubic_bezier",
|
||||
"have_bezier_curves_g1_continuity",
|
||||
"AnyBezier",
|
||||
"reverse_bezier_curves",
|
||||
"split_bezier",
|
||||
"quadratic_bezier_from_3p",
|
||||
"cubic_bezier_from_3p",
|
||||
"cubic_bezier_bbox",
|
||||
"quadratic_bezier_bbox",
|
||||
"intersection_ray_cubic_bezier_2d",
|
||||
]
|
||||
|
||||
|
||||
T = TypeVar("T", bound=AnyVec)
|
||||
|
||||
AnyBezier = Union[Bezier3P, Bezier4P]
|
||||
|
||||
|
||||
def quadratic_to_cubic_bezier(curve: Bezier3P) -> Bezier4P:
|
||||
"""Convert quadratic Bèzier curves (:class:`ezdxf.math.Bezier3P`) into
|
||||
cubic Bèzier curves (:class:`ezdxf.math.Bezier4P`).
|
||||
|
||||
"""
|
||||
start, control, end = curve.control_points
|
||||
control_1 = start + 2 * (control - start) / 3
|
||||
control_2 = end + 2 * (control - end) / 3
|
||||
return Bezier4P((start, control_1, control_2, end))
|
||||
|
||||
|
||||
def bezier_to_bspline(curves: Iterable[AnyBezier]) -> BSpline:
|
||||
"""Convert multiple quadratic or cubic Bèzier curves into a single cubic
|
||||
B-spline.
|
||||
|
||||
For good results the curves must be lined up seamlessly, i.e. the starting
|
||||
point of the following curve must be the same as the end point of the
|
||||
previous curve. G1 continuity or better at the connection points of the
|
||||
Bézier curves is required to get best results.
|
||||
|
||||
"""
|
||||
|
||||
# Source: https://math.stackexchange.com/questions/2960974/convert-continuous-bezier-curve-to-b-spline
|
||||
def get_points(bezier: AnyBezier):
|
||||
points = bezier.control_points
|
||||
if len(points) < 4:
|
||||
return quadratic_to_cubic_bezier(bezier).control_points
|
||||
else:
|
||||
return points
|
||||
|
||||
bezier_curve_points = [get_points(c) for c in curves]
|
||||
if len(bezier_curve_points) == 0:
|
||||
raise ValueError("one or more Bézier curves required")
|
||||
# Control points of the B-spline are the same as of the Bézier curves.
|
||||
# Remove duplicate control points at start and end of the curves.
|
||||
control_points = list(bezier_curve_points[0])
|
||||
for c in bezier_curve_points[1:]:
|
||||
control_points.extend(c[1:])
|
||||
knots = [0, 0, 0, 0] # multiplicity of the 1st and last control point is 4
|
||||
n = len(bezier_curve_points)
|
||||
for k in range(1, n):
|
||||
knots.extend((k, k, k)) # multiplicity of the inner control points is 3
|
||||
knots.extend((n, n, n, n))
|
||||
return BSpline(control_points, order=4, knots=knots)
|
||||
|
||||
|
||||
def have_bezier_curves_g1_continuity(
|
||||
b1: AnyBezier, b2: AnyBezier, g1_tol: float = 1e-4
|
||||
) -> bool:
|
||||
"""Return ``True`` if the given adjacent Bézier curves have G1 continuity.
|
||||
"""
|
||||
b1_pnts = tuple(b1.control_points)
|
||||
b2_pnts = tuple(b2.control_points)
|
||||
|
||||
if not b1_pnts[-1].isclose(b2_pnts[0]):
|
||||
return False # start- and end point are not close enough
|
||||
|
||||
try:
|
||||
te = (b1_pnts[-1] - b1_pnts[-2]).normalize()
|
||||
except ZeroDivisionError:
|
||||
return False # tangent calculation not possible
|
||||
|
||||
try:
|
||||
ts = (b2_pnts[1] - b2_pnts[0]).normalize()
|
||||
except ZeroDivisionError:
|
||||
return False # tangent calculation not possible
|
||||
|
||||
# 0 = normal; 1 = same direction; -1 = opposite direction
|
||||
return math.isclose(te.dot(ts), 1.0, abs_tol=g1_tol)
|
||||
|
||||
|
||||
def reverse_bezier_curves(curves: list[AnyBezier]) -> list[AnyBezier]:
|
||||
curves = list(c.reverse() for c in curves)
|
||||
curves.reverse()
|
||||
return curves
|
||||
|
||||
|
||||
def split_bezier(
|
||||
control_points: Sequence[T], t: float
|
||||
) -> tuple[list[T], list[T]]:
|
||||
"""Split a Bèzier curve at parameter `t`.
|
||||
|
||||
Returns the control points for two new Bèzier curves of the same degree
|
||||
and type as the input curve. (source: `pomax-1`_)
|
||||
|
||||
Args:
|
||||
control_points: of the Bèzier curve as :class:`Vec2` or :class:`Vec3`
|
||||
objects. Requires 3 points for a quadratic curve, 4 points for a
|
||||
cubic curve , ...
|
||||
t: parameter where to split the curve in the range [0, 1]
|
||||
|
||||
.. _pomax-1: https://pomax.github.io/bezierinfo/#splitting
|
||||
|
||||
"""
|
||||
if len(control_points) < 2:
|
||||
raise ValueError("2 or more control points required")
|
||||
if t < 0.0 or t > 1.0:
|
||||
raise ValueError("parameter `t` must be in range [0, 1]")
|
||||
left: list[T] = []
|
||||
right: list[T] = []
|
||||
|
||||
def split(points: Sequence[T]):
|
||||
n: int = len(points) - 1
|
||||
left.append(points[0])
|
||||
right.append(points[n])
|
||||
if n == 0:
|
||||
return
|
||||
split(
|
||||
tuple(points[i] * (1.0 - t) + points[i + 1] * t for i in range(n))
|
||||
)
|
||||
|
||||
split(control_points)
|
||||
return left, right
|
||||
|
||||
|
||||
def quadratic_bezier_from_3p(p1: UVec, p2: UVec, p3: UVec) -> Bezier3P:
|
||||
"""Returns a quadratic Bèzier curve :class:`Bezier3P` from three points.
|
||||
The curve starts at `p1`, goes through `p2` and ends at `p3`.
|
||||
(source: `pomax-2`_)
|
||||
|
||||
.. _pomax-2: https://pomax.github.io/bezierinfo/#pointcurves
|
||||
|
||||
"""
|
||||
|
||||
def u_func(t: float) -> float:
|
||||
mt = 1.0 - t
|
||||
mt2 = mt * mt
|
||||
return mt2 / (t * t + mt2)
|
||||
|
||||
def ratio(t: float) -> float:
|
||||
t2 = t * t
|
||||
mt = 1.0 - t
|
||||
mt2 = mt * mt
|
||||
return abs((t2 + mt2 - 1.0) / (t2 + mt2))
|
||||
|
||||
s = Vec3(p1)
|
||||
b = Vec3(p2)
|
||||
e = Vec3(p3)
|
||||
d1 = (s - b).magnitude
|
||||
d2 = (e - b).magnitude
|
||||
t = d1 / (d1 + d2)
|
||||
u = u_func(t)
|
||||
c = s * u + e * (1.0 - u)
|
||||
a = b + (b - c) / ratio(t)
|
||||
return Bezier3P([s, a, e])
|
||||
|
||||
|
||||
def cubic_bezier_from_3p(p1: UVec, p2: UVec, p3: UVec) -> Bezier4P:
|
||||
"""Returns a cubic Bèzier curve :class:`Bezier4P` from three points.
|
||||
The curve starts at `p1`, goes through `p2` and ends at `p3`.
|
||||
(source: `pomax-2`_)
|
||||
"""
|
||||
qbez = quadratic_bezier_from_3p(p1, p2, p3)
|
||||
return quadratic_to_cubic_bezier(qbez)
|
||||
|
||||
|
||||
def cubic_bezier_bbox(curve: Bezier4P, *, abs_tol=1e-12) -> BoundingBox:
|
||||
"""Returns the :class:`~ezdxf.math.BoundingBox` of a cubic Bézier curve
|
||||
of type :class:`~ezdxf.math.Bezier4P`.
|
||||
"""
|
||||
cp = curve.control_points
|
||||
points: list[Vec3] = [cp[0], cp[3]]
|
||||
for p1, p2, p3, p4 in zip(*cp):
|
||||
a = 3.0 * (-p1 + 3.0 * p2 - 3.0 * p3 + p4)
|
||||
b = 6.0 * (p1 - 2.0 * p2 + p3)
|
||||
c = 3.0 * (p2 - p1)
|
||||
if abs(a) < abs_tol:
|
||||
if abs(b) < abs_tol:
|
||||
t = -c # or skip this case?
|
||||
else:
|
||||
t = -c / b
|
||||
if 0.0 < t < 1.0:
|
||||
points.append((curve.point(t)))
|
||||
continue
|
||||
|
||||
try:
|
||||
sqrt_bb4ac = math.sqrt(b * b - 4.0 * a * c)
|
||||
except ValueError: # domain error
|
||||
continue
|
||||
aa = 2.0 * a
|
||||
t = (-b + sqrt_bb4ac) / aa
|
||||
if 0.0 < t < 1.0:
|
||||
points.append(curve.point(t))
|
||||
t = (-b - sqrt_bb4ac) / aa
|
||||
if 0.0 < t < 1.0:
|
||||
points.append(curve.point(t))
|
||||
return BoundingBox(points)
|
||||
|
||||
|
||||
def quadratic_bezier_bbox(curve: Bezier3P, *, abs_tol=1e-12) -> BoundingBox:
|
||||
"""Returns the :class:`~ezdxf.math.BoundingBox` of a quadratic Bézier curve
|
||||
of type :class:`~ezdxf.math.Bezier3P`.
|
||||
"""
|
||||
return cubic_bezier_bbox(quadratic_to_cubic_bezier(curve), abs_tol=abs_tol)
|
||||
|
||||
|
||||
def _bezier4poly(a: float, b: float, c: float, d: float):
|
||||
a3 = a * 3.0
|
||||
b3 = b * 3.0
|
||||
c3 = c * 3.0
|
||||
return -a + b3 - c3 + d, a3 - b * 6.0 + c3, -a3 + b3, a
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def intersection_params_ray_cubic_bezier(
|
||||
p0: AnyVec, p1: AnyVec, cp: Sequence[AnyVec]
|
||||
) -> list[float]:
|
||||
"""Returns the parameters of the intersection points between the ray defined
|
||||
by two points `p0` and `p1` and the cubic Bézier curve defined by four
|
||||
control points `cp`.
|
||||
"""
|
||||
A = p1.y - p0.y
|
||||
B = p0.x - p1.x
|
||||
C = p0.x * (p0.y - p1.y) + p0.y * (p1.x - p0.x)
|
||||
|
||||
c0, c1, c2, c3 = cp
|
||||
bx = _bezier4poly(c0.x, c1.x, c2.x, c3.x)
|
||||
by = _bezier4poly(c0.y, c1.y, c2.y, c3.y)
|
||||
return sorted(
|
||||
v
|
||||
for v in cubic_equation(
|
||||
A * bx[0] + B * by[0],
|
||||
A * bx[1] + B * by[1],
|
||||
A * bx[2] + B * by[2],
|
||||
A * bx[3] + B * by[3] + C,
|
||||
)
|
||||
if 0.0 <= v <= 1.0
|
||||
)
|
||||
|
||||
|
||||
def intersection_ray_cubic_bezier_2d(
|
||||
p0: UVec,
|
||||
p1: UVec,
|
||||
curve: Bezier4P,
|
||||
) -> Sequence[Vec2]:
|
||||
"""Returns the intersection points between the `ray` defined by two points
|
||||
`p0` and `p1` and the given cubic Bézier `curve`. Ignores the z-axis of 3D
|
||||
curves.
|
||||
|
||||
Returns 0-3 intersection points as :class:`Vec2` objects in the
|
||||
order start- to end point of the curve.
|
||||
|
||||
"""
|
||||
return Vec2.tuple(
|
||||
curve.point(t)
|
||||
for t in intersection_params_ray_cubic_bezier(
|
||||
Vec2(p0), Vec2(p1), curve.control_points
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,599 @@
|
||||
# Copyright (c) 2020-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Iterable, Any
|
||||
import numpy as np
|
||||
|
||||
import math
|
||||
from ezdxf.math import (
|
||||
Vec3,
|
||||
UVec,
|
||||
NULLVEC,
|
||||
X_AXIS,
|
||||
Z_AXIS,
|
||||
OCS,
|
||||
Matrix44,
|
||||
arc_angle_span_rad,
|
||||
distance_point_line_3d,
|
||||
enclosing_angles,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.layouts import BaseLayout
|
||||
from ezdxf.entities import Ellipse
|
||||
|
||||
__all__ = [
|
||||
"ConstructionEllipse",
|
||||
"angle_to_param",
|
||||
"param_to_angle",
|
||||
"rytz_axis_construction",
|
||||
]
|
||||
QUARTER_PARAMS = [0, math.pi * 0.5, math.pi, math.pi * 1.5]
|
||||
HALF_PI = math.pi / 2.0
|
||||
|
||||
|
||||
class ConstructionEllipse:
|
||||
"""Construction tool for 3D ellipsis.
|
||||
|
||||
Args:
|
||||
center: 3D center point
|
||||
major_axis: major axis as 3D vector
|
||||
extrusion: normal vector of ellipse plane
|
||||
ratio: ratio of minor axis to major axis
|
||||
start_param: start param in radians
|
||||
end_param: end param in radians
|
||||
ccw: is counter-clockwise flag - swaps start- and end param if ``False``
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
center: UVec = NULLVEC,
|
||||
major_axis: UVec = X_AXIS,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
ratio: float = 1,
|
||||
start_param: float = 0,
|
||||
end_param: float = math.tau,
|
||||
ccw: bool = True,
|
||||
):
|
||||
self.center = Vec3(center)
|
||||
self.major_axis = Vec3(major_axis)
|
||||
if self.major_axis.isclose(NULLVEC):
|
||||
raise ValueError(f"Invalid major axis (null vector).")
|
||||
self.extrusion = Vec3(extrusion)
|
||||
if self.major_axis.isclose(NULLVEC):
|
||||
raise ValueError(f"Invalid extrusion vector (null vector).")
|
||||
self.ratio = float(ratio)
|
||||
self.start_param = float(start_param)
|
||||
self.end_param = float(end_param)
|
||||
if not ccw:
|
||||
self.start_param, self.end_param = self.end_param, self.start_param
|
||||
self.minor_axis = minor_axis(self.major_axis, self.extrusion, self.ratio)
|
||||
|
||||
@classmethod
|
||||
def from_arc(
|
||||
cls,
|
||||
center: UVec = NULLVEC,
|
||||
radius: float = 1,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
start_angle: float = 0,
|
||||
end_angle: float = 360,
|
||||
ccw: bool = True,
|
||||
) -> ConstructionEllipse:
|
||||
"""Returns :class:`ConstructionEllipse` from arc or circle.
|
||||
|
||||
Arc and Circle parameters defined in OCS.
|
||||
|
||||
Args:
|
||||
center: center in OCS
|
||||
radius: arc or circle radius
|
||||
extrusion: OCS extrusion vector
|
||||
start_angle: start angle in degrees
|
||||
end_angle: end angle in degrees
|
||||
ccw: arc curve goes counter clockwise from start to end if ``True``
|
||||
"""
|
||||
radius = abs(radius)
|
||||
if NULLVEC.isclose(extrusion):
|
||||
raise ValueError(f"Invalid extrusion: {str(extrusion)}")
|
||||
ratio = 1.0
|
||||
ocs = OCS(extrusion)
|
||||
center = ocs.to_wcs(center)
|
||||
# Major axis along the OCS x-axis.
|
||||
major_axis = ocs.to_wcs(Vec3(radius, 0, 0))
|
||||
# No further adjustment of start- and end angle required.
|
||||
start_param = math.radians(start_angle)
|
||||
end_param = math.radians(end_angle)
|
||||
return cls(
|
||||
center,
|
||||
major_axis,
|
||||
extrusion,
|
||||
ratio,
|
||||
start_param,
|
||||
end_param,
|
||||
bool(ccw),
|
||||
)
|
||||
|
||||
def __copy__(self):
|
||||
return self.__class__(
|
||||
self.center,
|
||||
self.major_axis,
|
||||
self.extrusion,
|
||||
self.ratio,
|
||||
self.start_param,
|
||||
self.end_param,
|
||||
)
|
||||
|
||||
@property
|
||||
def start_point(self) -> Vec3:
|
||||
"""Returns start point of ellipse as Vec3."""
|
||||
return vertex(
|
||||
self.start_param,
|
||||
self.major_axis,
|
||||
self.minor_axis,
|
||||
self.center,
|
||||
self.ratio,
|
||||
)
|
||||
|
||||
@property
|
||||
def end_point(self) -> Vec3:
|
||||
"""Returns end point of ellipse as Vec3."""
|
||||
return vertex(
|
||||
self.end_param,
|
||||
self.major_axis,
|
||||
self.minor_axis,
|
||||
self.center,
|
||||
self.ratio,
|
||||
)
|
||||
|
||||
def dxfattribs(self) -> dict[str, Any]:
|
||||
"""Returns required DXF attributes to build an ELLIPSE entity.
|
||||
|
||||
Entity ELLIPSE has always a ratio in range from 1e-6 to 1.
|
||||
|
||||
"""
|
||||
if self.ratio > 1:
|
||||
e = self.__copy__()
|
||||
e.swap_axis()
|
||||
else:
|
||||
e = self
|
||||
return {
|
||||
"center": e.center,
|
||||
"major_axis": e.major_axis,
|
||||
"extrusion": e.extrusion,
|
||||
"ratio": max(e.ratio, 1e-6),
|
||||
"start_param": e.start_param,
|
||||
"end_param": e.end_param,
|
||||
}
|
||||
|
||||
def main_axis_points(self) -> Iterable[Vec3]:
|
||||
"""Yields main axis points of ellipse in the range from start- to end
|
||||
param.
|
||||
"""
|
||||
start = self.start_param
|
||||
end = self.end_param
|
||||
for param in QUARTER_PARAMS:
|
||||
if enclosing_angles(param, start, end):
|
||||
yield vertex(
|
||||
param,
|
||||
self.major_axis,
|
||||
self.minor_axis,
|
||||
self.center,
|
||||
self.ratio,
|
||||
)
|
||||
|
||||
def transform(self, m: Matrix44) -> None:
|
||||
"""Transform ellipse in place by transformation matrix `m`."""
|
||||
new_center = m.transform(self.center)
|
||||
# 2021-01-28 removed % math.tau
|
||||
old_start_param = start_param = self.start_param
|
||||
old_end_param = end_param = self.end_param
|
||||
old_minor_axis = minor_axis(self.major_axis, self.extrusion, self.ratio)
|
||||
new_major_axis, new_minor_axis = m.transform_directions(
|
||||
(self.major_axis, old_minor_axis)
|
||||
)
|
||||
# Original ellipse parameters stay untouched until end of transformation
|
||||
dot_product = new_major_axis.normalize().dot(new_minor_axis.normalize())
|
||||
if abs(dot_product) > 1e-6:
|
||||
new_major_axis, new_minor_axis, new_ratio = rytz_axis_construction(
|
||||
new_major_axis, new_minor_axis
|
||||
)
|
||||
new_extrusion = new_major_axis.cross(new_minor_axis).normalize()
|
||||
adjust_params = True
|
||||
else:
|
||||
# New axis are nearly orthogonal:
|
||||
new_ratio = new_minor_axis.magnitude / new_major_axis.magnitude
|
||||
# New normal vector:
|
||||
new_extrusion = new_major_axis.cross(new_minor_axis).normalize()
|
||||
# Calculate exact minor axis:
|
||||
new_minor_axis = minor_axis(new_major_axis, new_extrusion, new_ratio)
|
||||
adjust_params = False
|
||||
|
||||
if adjust_params and not math.isclose(start_param, end_param, abs_tol=1e-9):
|
||||
# open ellipse, adjusting start- and end parameter
|
||||
x_axis = new_major_axis.normalize()
|
||||
y_axis = new_minor_axis.normalize()
|
||||
# TODO: use ellipse_param_span()?
|
||||
# 2021-01-28 this is possibly the source of errors!
|
||||
old_param_span = (end_param - start_param) % math.tau
|
||||
|
||||
def param(vec: "Vec3") -> float:
|
||||
dy = y_axis.dot(vec) / new_ratio # adjust to circle
|
||||
dx = x_axis.dot(vec)
|
||||
return math.atan2(dy, dx) % math.tau
|
||||
|
||||
# transformed start- and end point of old ellipse
|
||||
start_point = m.transform(
|
||||
vertex(
|
||||
start_param,
|
||||
self.major_axis,
|
||||
old_minor_axis,
|
||||
self.center,
|
||||
self.ratio,
|
||||
)
|
||||
)
|
||||
end_point = m.transform(
|
||||
vertex(
|
||||
end_param,
|
||||
self.major_axis,
|
||||
old_minor_axis,
|
||||
self.center,
|
||||
self.ratio,
|
||||
)
|
||||
)
|
||||
|
||||
start_param = param(start_point - new_center)
|
||||
end_param = param(end_point - new_center)
|
||||
|
||||
# Test if drawing the correct side of the curve
|
||||
if not math.isclose(old_param_span, math.pi, abs_tol=1e-9):
|
||||
# Equal param span check works well, except for a span of exact
|
||||
# pi (180 deg).
|
||||
# TODO: use ellipse_param_span()?
|
||||
# 2021-01-28 this is possibly the source of errors!
|
||||
new_param_span = (end_param - start_param) % math.tau
|
||||
if not math.isclose(old_param_span, new_param_span, abs_tol=1e-9):
|
||||
start_param, end_param = end_param, start_param
|
||||
else: # param span is exact pi (180 deg)
|
||||
# expensive but it seem to work:
|
||||
old_chk_point = m.transform(
|
||||
vertex(
|
||||
mid_param(old_start_param, old_end_param),
|
||||
self.major_axis,
|
||||
old_minor_axis,
|
||||
self.center,
|
||||
self.ratio,
|
||||
)
|
||||
)
|
||||
new_chk_point = vertex(
|
||||
mid_param(start_param, end_param),
|
||||
new_major_axis,
|
||||
new_minor_axis,
|
||||
new_center,
|
||||
new_ratio,
|
||||
)
|
||||
if not old_chk_point.isclose(new_chk_point, abs_tol=1e-9):
|
||||
start_param, end_param = end_param, start_param
|
||||
|
||||
if new_ratio > 1:
|
||||
new_major_axis = minor_axis(new_major_axis, new_extrusion, new_ratio)
|
||||
new_ratio = 1.0 / new_ratio
|
||||
new_minor_axis = minor_axis(new_major_axis, new_extrusion, new_ratio)
|
||||
if not (math.isclose(start_param, 0) and math.isclose(end_param, math.tau)):
|
||||
start_param -= HALF_PI
|
||||
end_param -= HALF_PI
|
||||
|
||||
# TODO: remove normalize start- and end params?
|
||||
# 2021-01-28 this is possibly the source of errors!
|
||||
start_param = start_param % math.tau
|
||||
end_param = end_param % math.tau
|
||||
if math.isclose(start_param, end_param):
|
||||
start_param = 0.0
|
||||
end_param = math.tau
|
||||
|
||||
self.center = new_center
|
||||
self.major_axis = new_major_axis
|
||||
self.minor_axis = new_minor_axis
|
||||
self.extrusion = new_extrusion
|
||||
self.ratio = new_ratio
|
||||
self.start_param = start_param
|
||||
self.end_param = end_param
|
||||
|
||||
@property
|
||||
def param_span(self) -> float:
|
||||
"""Returns the counter-clockwise params span from start- to end param,
|
||||
see also :func:`ezdxf.math.ellipse_param_span` for more information.
|
||||
|
||||
"""
|
||||
return arc_angle_span_rad(self.start_param, self.end_param)
|
||||
|
||||
def params(self, num: int) -> Iterable[float]:
|
||||
"""Returns `num` params from start- to end param in counter-clockwise
|
||||
order.
|
||||
|
||||
All params are normalized in the range from [0, 2π).
|
||||
|
||||
"""
|
||||
yield from get_params(self.start_param, self.end_param, num)
|
||||
|
||||
def vertices(self, params: Iterable[float]) -> Iterable[Vec3]:
|
||||
"""Yields vertices on ellipse for iterable `params` in WCS.
|
||||
|
||||
Args:
|
||||
params: param values in the range from [0, 2π) in radians,
|
||||
param goes counter-clockwise around the extrusion vector,
|
||||
major_axis = local x-axis = 0 rad.
|
||||
|
||||
"""
|
||||
center = self.center
|
||||
ratio = self.ratio
|
||||
x_axis = self.major_axis.normalize()
|
||||
y_axis = self.minor_axis.normalize()
|
||||
radius_x = self.major_axis.magnitude
|
||||
radius_y = radius_x * ratio
|
||||
|
||||
for param in params:
|
||||
x = math.cos(param) * radius_x * x_axis
|
||||
y = math.sin(param) * radius_y * y_axis
|
||||
yield center + x + y
|
||||
|
||||
def flattening(self, distance: float, segments: int = 4) -> Iterable[Vec3]:
|
||||
"""Adaptive recursive flattening. The argument `segments` is the
|
||||
minimum count of approximation segments, if the distance from the center
|
||||
of the approximation segment to the curve is bigger than `distance` the
|
||||
segment will be subdivided. Returns a closed polygon for a full ellipse:
|
||||
start vertex == end vertex.
|
||||
|
||||
Args:
|
||||
distance: maximum distance from the projected curve point onto the
|
||||
segment chord.
|
||||
segments: minimum segment count
|
||||
|
||||
"""
|
||||
|
||||
def vertex_(p: float) -> Vec3:
|
||||
x = math.cos(p) * radius_x * x_axis
|
||||
y = math.sin(p) * radius_y * y_axis
|
||||
return self.center + x + y
|
||||
|
||||
def subdiv(s: Vec3, e: Vec3, s_param: float, e_param: float):
|
||||
m_param = (s_param + e_param) * 0.5
|
||||
m = vertex_(m_param)
|
||||
d = distance_point_line_3d(m, s, e)
|
||||
if d < distance:
|
||||
yield e
|
||||
else:
|
||||
yield from subdiv(s, m, s_param, m_param)
|
||||
yield from subdiv(m, e, m_param, e_param)
|
||||
|
||||
x_axis = self.major_axis.normalize()
|
||||
y_axis = self.minor_axis.normalize()
|
||||
radius_x = self.major_axis.magnitude
|
||||
radius_y = radius_x * self.ratio
|
||||
|
||||
delta = self.param_span / segments
|
||||
if delta == 0.0:
|
||||
return
|
||||
|
||||
param = self.start_param % math.tau
|
||||
if math.isclose(self.end_param, math.tau):
|
||||
end_param = math.tau
|
||||
else:
|
||||
end_param = self.end_param % math.tau
|
||||
|
||||
if math.isclose(param, end_param):
|
||||
return
|
||||
elif param > end_param:
|
||||
end_param += math.tau
|
||||
|
||||
start_point = vertex_(param)
|
||||
yield start_point
|
||||
while param < end_param:
|
||||
next_end_param = param + delta
|
||||
if math.isclose(next_end_param, end_param):
|
||||
next_end_param = end_param
|
||||
end_point = vertex_(next_end_param)
|
||||
yield from subdiv(start_point, end_point, param, next_end_param)
|
||||
param = next_end_param
|
||||
start_point = end_point
|
||||
|
||||
def params_from_vertices(self, vertices: Iterable[UVec]) -> Iterable[float]:
|
||||
"""Yields ellipse params for all given `vertices`.
|
||||
|
||||
The vertex don't have to be exact on the ellipse curve or in the range
|
||||
from start- to end param or even in the ellipse plane. Param is
|
||||
calculated from the intersection point of the ray projected on the
|
||||
ellipse plane from the center of the ellipse through the vertex.
|
||||
|
||||
.. warning::
|
||||
|
||||
An input for start- and end vertex at param 0 and 2π return
|
||||
unpredictable results because of floating point inaccuracy,
|
||||
sometimes 0 and sometimes 2π.
|
||||
|
||||
"""
|
||||
x_axis = self.major_axis.normalize()
|
||||
y_axis = self.minor_axis.normalize()
|
||||
ratio = self.ratio
|
||||
center = self.center
|
||||
for v in Vec3.generate(vertices):
|
||||
v -= center
|
||||
yield math.atan2(y_axis.dot(v) / ratio, x_axis.dot(v)) % math.tau
|
||||
|
||||
def tangents(self, params: Iterable[float]) -> Iterable[Vec3]:
|
||||
"""Yields tangents on ellipse for iterable `params` in WCS as direction
|
||||
vectors.
|
||||
|
||||
Args:
|
||||
params: param values in the range from [0, 2π] in radians, param
|
||||
goes counter-clockwise around the extrusion vector,
|
||||
major_axis = local x-axis = 0 rad.
|
||||
|
||||
"""
|
||||
ratio = self.ratio
|
||||
x_axis = self.major_axis.normalize()
|
||||
y_axis = self.minor_axis.normalize()
|
||||
|
||||
for param in params:
|
||||
x = -math.sin(param) * x_axis
|
||||
y = math.cos(param) * ratio * y_axis
|
||||
yield (x + y).normalize()
|
||||
|
||||
def swap_axis(self) -> None:
|
||||
"""Swap axis and adjust start- and end parameter."""
|
||||
self.major_axis = self.minor_axis
|
||||
ratio = 1.0 / self.ratio
|
||||
self.ratio = max(ratio, 1e-6)
|
||||
self.minor_axis = minor_axis(self.major_axis, self.extrusion, self.ratio)
|
||||
|
||||
start_param = self.start_param
|
||||
end_param = self.end_param
|
||||
if math.isclose(start_param, 0) and math.isclose(end_param, math.tau):
|
||||
return
|
||||
self.start_param = (start_param - HALF_PI) % math.tau
|
||||
self.end_param = (end_param - HALF_PI) % math.tau
|
||||
|
||||
def add_to_layout(self, layout: BaseLayout, dxfattribs=None) -> Ellipse:
|
||||
"""Add ellipse as DXF :class:`~ezdxf.entities.Ellipse` entity to a
|
||||
layout.
|
||||
|
||||
Args:
|
||||
layout: destination layout as :class:`~ezdxf.layouts.BaseLayout`
|
||||
object
|
||||
dxfattribs: additional DXF attributes for the ELLIPSE entity
|
||||
|
||||
"""
|
||||
from ezdxf.entities import Ellipse
|
||||
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
dxfattribs.update(self.dxfattribs())
|
||||
e = Ellipse.new(dxfattribs=dxfattribs, doc=layout.doc)
|
||||
layout.add_entity(e)
|
||||
return e
|
||||
|
||||
def to_ocs(self) -> ConstructionEllipse:
|
||||
"""Returns ellipse parameters as OCS representation.
|
||||
|
||||
OCS elevation is stored in :attr:`center.z`.
|
||||
|
||||
"""
|
||||
ocs = OCS(self.extrusion)
|
||||
return self.__class__(
|
||||
center=ocs.from_wcs(self.center),
|
||||
major_axis=ocs.from_wcs(self.major_axis).replace(z=0.0),
|
||||
ratio=self.ratio,
|
||||
start_param=self.start_param,
|
||||
end_param=self.end_param,
|
||||
)
|
||||
|
||||
|
||||
def mid_param(start: float, end: float) -> float:
|
||||
if end < start:
|
||||
end += math.tau
|
||||
return (start + end) / 2.0
|
||||
|
||||
|
||||
def minor_axis(major_axis: Vec3, extrusion: Vec3, ratio: float) -> Vec3:
|
||||
return extrusion.cross(major_axis).normalize(major_axis.magnitude * ratio)
|
||||
|
||||
|
||||
def vertex(
|
||||
param: float, major_axis: Vec3, minor_axis: Vec3, center: Vec3, ratio: float
|
||||
) -> Vec3:
|
||||
x_axis = major_axis.normalize()
|
||||
y_axis = minor_axis.normalize()
|
||||
radius_x = major_axis.magnitude
|
||||
radius_y = radius_x * ratio
|
||||
x = math.cos(param) * radius_x * x_axis
|
||||
y = math.sin(param) * radius_y * y_axis
|
||||
return center + x + y
|
||||
|
||||
|
||||
def get_params(start: float, end: float, num: int) -> Iterable[float]:
|
||||
"""Returns `num` params from start- to end param in counter-clockwise order.
|
||||
|
||||
All params are normalized in the range from [0, 2π).
|
||||
|
||||
"""
|
||||
if num < 2:
|
||||
raise ValueError("num >= 2")
|
||||
if end <= start:
|
||||
end += math.tau
|
||||
|
||||
for param in np.linspace(start, end, num):
|
||||
yield param % math.tau
|
||||
|
||||
|
||||
def angle_to_param(ratio: float, angle: float) -> float:
|
||||
"""Returns ellipse parameter for argument `angle`.
|
||||
|
||||
Args:
|
||||
ratio: minor axis to major axis ratio as stored in the ELLIPSE entity
|
||||
(always <= 1).
|
||||
angle: angle between major axis and line from center to point on the
|
||||
ellipse
|
||||
|
||||
Returns:
|
||||
the ellipse parameter in the range [0, 2π)
|
||||
"""
|
||||
return math.atan2(math.sin(angle) / ratio, math.cos(angle)) % math.tau
|
||||
|
||||
|
||||
def param_to_angle(ratio: float, param: float) -> float:
|
||||
"""Returns circle angle from ellipse parameter for argument `angle`.
|
||||
|
||||
Args:
|
||||
ratio: minor axis to major axis ratio as stored in the ELLIPSE entity
|
||||
(always <= 1).
|
||||
param: ellipse parameter between major axis and point on the ellipse
|
||||
curve
|
||||
|
||||
Returns:
|
||||
the circle angle in the range [0, 2π)
|
||||
"""
|
||||
return math.atan2(math.sin(param) * ratio, math.cos(param))
|
||||
|
||||
|
||||
def rytz_axis_construction(d1: Vec3, d2: Vec3) -> tuple[Vec3, Vec3, float]:
|
||||
"""The Rytz’s axis construction is a basic method of descriptive Geometry
|
||||
to find the axes, the semi-major axis and semi-minor axis, starting from two
|
||||
conjugated half-diameters.
|
||||
|
||||
Source: `Wikipedia <https://en.m.wikipedia.org/wiki/Rytz%27s_construction>`_
|
||||
|
||||
Given conjugated diameter `d1` is the vector from center C to point P and
|
||||
the given conjugated diameter `d2` is the vector from center C to point Q.
|
||||
Center of ellipse is always ``(0, 0, 0)``. This algorithm works for
|
||||
2D/3D vectors.
|
||||
|
||||
Args:
|
||||
d1: conjugated semi-major axis as :class:`Vec3`
|
||||
d2: conjugated semi-minor axis as :class:`Vec3`
|
||||
|
||||
Returns:
|
||||
Tuple of (major axis, minor axis, ratio)
|
||||
|
||||
"""
|
||||
Q = Vec3(d1) # vector CQ
|
||||
# calculate vector CP', location P'
|
||||
if math.isclose(d1.z, 0, abs_tol=1e-9) and math.isclose(d2.z, 0, abs_tol=1e-9):
|
||||
# Vec3.orthogonal() works only for vectors in the xy-plane!
|
||||
P1 = Vec3(d2).orthogonal(ccw=False)
|
||||
else:
|
||||
extrusion = d1.cross(d2)
|
||||
P1 = extrusion.cross(d2).normalize(d2.magnitude)
|
||||
|
||||
D = P1.lerp(Q) # vector CD, location D, midpoint of P'Q
|
||||
radius = D.magnitude
|
||||
radius_vector = (Q - P1).normalize(radius) # direction vector P'Q
|
||||
A = D - radius_vector # vector CA, location A
|
||||
B = D + radius_vector # vector CB, location B
|
||||
if A.isclose(NULLVEC) or B.isclose(NULLVEC):
|
||||
raise ArithmeticError("Conjugated axis required, invalid source data.")
|
||||
major_axis_length = (A - Q).magnitude
|
||||
minor_axis_length = (B - Q).magnitude
|
||||
if math.isclose(major_axis_length, 0.0) or math.isclose(minor_axis_length, 0.0):
|
||||
raise ArithmeticError("Conjugated axis required, invalid source data.")
|
||||
ratio = minor_axis_length / major_axis_length
|
||||
major_axis = B.normalize(major_axis_length)
|
||||
minor_axis = A.normalize(minor_axis_length)
|
||||
return major_axis, minor_axis, ratio
|
||||
@@ -0,0 +1,136 @@
|
||||
# Copyright (c) 2010-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable
|
||||
from ezdxf.math import Vec3
|
||||
from ezdxf.math.bspline import global_bspline_interpolation, BSpline
|
||||
|
||||
__all__ = ["EulerSpiral"]
|
||||
|
||||
|
||||
def powers(base: float, count: int) -> list[float]:
|
||||
assert count > 2, "requires count > 2"
|
||||
values = [1.0, base]
|
||||
next_value = base
|
||||
for _ in range(count - 2):
|
||||
next_value *= base
|
||||
values.append(next_value)
|
||||
return values
|
||||
|
||||
|
||||
def _params(length: float, segments: int) -> Iterable[float]:
|
||||
delta_l = float(length) / float(segments)
|
||||
for index in range(0, segments + 1):
|
||||
yield delta_l * index
|
||||
|
||||
|
||||
class EulerSpiral:
|
||||
"""
|
||||
This class represents an euler spiral (clothoid) for `curvature` (Radius of
|
||||
curvature).
|
||||
|
||||
This is a parametric curve, which always starts at the origin = ``(0, 0)``.
|
||||
|
||||
Args:
|
||||
curvature: radius of curvature
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, curvature: float = 1.0):
|
||||
curvature = float(curvature)
|
||||
self.curvature = curvature # Radius of curvature
|
||||
self.curvature_powers: list[float] = powers(curvature, 19)
|
||||
self._cache: dict[float, Vec3] = {} # coordinates cache
|
||||
|
||||
def radius(self, t: float) -> float:
|
||||
"""Get radius of circle at distance `t`."""
|
||||
if t > 0.0:
|
||||
return self.curvature_powers[2] / t
|
||||
else:
|
||||
return 0.0 # radius = infinite
|
||||
|
||||
def tangent(self, t: float) -> Vec3:
|
||||
"""Get tangent at distance `t` as :class:`Vec3` object."""
|
||||
angle = t ** 2 / (2.0 * self.curvature_powers[2])
|
||||
return Vec3.from_angle(angle)
|
||||
|
||||
def distance(self, radius: float) -> float:
|
||||
"""Get distance L from origin for `radius`."""
|
||||
return self.curvature_powers[2] / float(radius)
|
||||
|
||||
def point(self, t: float) -> Vec3:
|
||||
"""Get point at distance `t` as :class:`Vec3`."""
|
||||
|
||||
def term(length_power, curvature_power, const):
|
||||
return t ** length_power / (
|
||||
const * self.curvature_powers[curvature_power]
|
||||
)
|
||||
|
||||
if t not in self._cache:
|
||||
y = (
|
||||
term(3, 2, 6.0)
|
||||
- term(7, 6, 336.0)
|
||||
+ term(11, 10, 42240.0)
|
||||
- term(15, 14, 9676800.0)
|
||||
+ term(19, 18, 3530096640.0)
|
||||
)
|
||||
x = (
|
||||
t
|
||||
- term(5, 4, 40.0)
|
||||
+ term(9, 8, 3456.0)
|
||||
- term(13, 12, 599040.0)
|
||||
+ term(17, 16, 175472640.0)
|
||||
)
|
||||
self._cache[t] = Vec3(x, y)
|
||||
return self._cache[t]
|
||||
|
||||
def approximate(self, length: float, segments: int) -> Iterable[Vec3]:
|
||||
"""Approximate curve of length with line segments.
|
||||
Generates segments+1 vertices as :class:`Vec3` objects.
|
||||
|
||||
"""
|
||||
for t in _params(length, segments):
|
||||
yield self.point(t)
|
||||
|
||||
def circle_center(self, t: float) -> Vec3:
|
||||
"""Get circle center at distance `t`."""
|
||||
p = self.point(t)
|
||||
r = self.radius(t)
|
||||
return p + self.tangent(t).normalize(r).orthogonal()
|
||||
|
||||
def bspline(
|
||||
self,
|
||||
length: float,
|
||||
segments: int = 10,
|
||||
degree: int = 3,
|
||||
method: str = "uniform",
|
||||
) -> BSpline:
|
||||
"""Approximate euler spiral as B-spline.
|
||||
|
||||
Args:
|
||||
length: length of euler spiral
|
||||
segments: count of fit points for B-spline calculation
|
||||
degree: degree of BSpline
|
||||
method: calculation method for parameter vector t
|
||||
|
||||
Returns:
|
||||
:class:`BSpline`
|
||||
|
||||
"""
|
||||
length = float(length)
|
||||
fit_points = list(self.approximate(length, segments=segments))
|
||||
derivatives = [
|
||||
# Scaling derivatives by chord length (< real length) is suggested
|
||||
# by Piegl & Tiller.
|
||||
self.tangent(t).normalize(length)
|
||||
for t in _params(length, segments)
|
||||
]
|
||||
spline = global_bspline_interpolation(
|
||||
fit_points, degree, method=method, tangents=derivatives
|
||||
)
|
||||
return BSpline(
|
||||
spline.control_points,
|
||||
spline.order,
|
||||
# Scale knot values to length:
|
||||
[v * length for v in spline.knots()],
|
||||
)
|
||||
@@ -0,0 +1,417 @@
|
||||
# Copyright (c) 2018-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# legacy.py code is replaced by numpy - exists just for benchmarking, testing and nostalgia
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, cast
|
||||
from itertools import repeat
|
||||
import reprlib
|
||||
|
||||
from .linalg import Matrix, MatrixData, NDArray, Solver
|
||||
|
||||
__all__ = [
|
||||
"gauss_vector_solver",
|
||||
"gauss_matrix_solver",
|
||||
"gauss_jordan_solver",
|
||||
"gauss_jordan_inverse",
|
||||
"LUDecomposition",
|
||||
]
|
||||
|
||||
|
||||
def copy_float_matrix(A) -> MatrixData:
|
||||
if isinstance(A, Matrix):
|
||||
A = A.matrix
|
||||
return [[float(v) for v in row] for row in A]
|
||||
|
||||
|
||||
def gauss_vector_solver(A: MatrixData | NDArray, B: Iterable[float]) -> list[float]:
|
||||
"""Solves the linear equation system given by a nxn Matrix A . x = B,
|
||||
right-hand side quantities as vector B with n elements by the
|
||||
`Gauss-Elimination`_ algorithm, which is faster than the `Gauss-Jordan`_
|
||||
algorithm. The speed improvement is more significant for solving multiple
|
||||
right-hand side quantities as matrix at once.
|
||||
|
||||
Reference implementation for error checking.
|
||||
|
||||
Args:
|
||||
A: matrix [[a11, a12, ..., a1n], [a21, a22, ..., a2n], [a21, a22, ..., a2n],
|
||||
... [an1, an2, ..., ann]]
|
||||
B: vector [b1, b2, ..., bn]
|
||||
|
||||
Returns:
|
||||
vector as list of floats
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
# copy input data
|
||||
A = copy_float_matrix(A)
|
||||
B = list(B)
|
||||
num = len(A)
|
||||
if len(A[0]) != num:
|
||||
raise ValueError("A square nxn matrix A is required.")
|
||||
if len(B) != num:
|
||||
raise ValueError(
|
||||
"Item count of vector B has to be equal to matrix A row count."
|
||||
)
|
||||
|
||||
# inplace modification of A & B
|
||||
_build_upper_triangle(A, B)
|
||||
return _backsubstitution(A, B)
|
||||
|
||||
|
||||
def gauss_matrix_solver(A: MatrixData | NDArray, B: MatrixData | NDArray) -> Matrix:
|
||||
"""Solves the linear equation system given by a nxn Matrix A . x = B,
|
||||
right-hand side quantities as nxm Matrix B by the `Gauss-Elimination`_
|
||||
algorithm, which is faster than the `Gauss-Jordan`_ algorithm.
|
||||
|
||||
Reference implementation for error checking.
|
||||
|
||||
Args:
|
||||
A: matrix [[a11, a12, ..., a1n], [a21, a22, ..., a2n], [a21, a22, ..., a2n],
|
||||
... [an1, an2, ..., ann]]
|
||||
B: matrix [[b11, b12, ..., b1m], [b21, b22, ..., b2m], ... [bn1, bn2, ..., bnm]]
|
||||
|
||||
Returns:
|
||||
matrix as :class:`Matrix` object
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
# copy input data
|
||||
matrix_a = copy_float_matrix(A)
|
||||
matrix_b = copy_float_matrix(B)
|
||||
num = len(matrix_a)
|
||||
if len(matrix_a[0]) != num:
|
||||
raise ValueError("A square nxn matrix A is required.")
|
||||
if len(matrix_b) != num:
|
||||
raise ValueError("Row count of matrices A and B has to match.")
|
||||
|
||||
# inplace modification of A & B
|
||||
_build_upper_triangle(matrix_a, matrix_b)
|
||||
|
||||
columns = Matrix(matrix=matrix_b).cols()
|
||||
result = Matrix()
|
||||
for col in columns:
|
||||
result.append_col(_backsubstitution(matrix_a, col))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_upper_triangle(A: MatrixData, B: list) -> None:
|
||||
"""Build upper triangle for backsubstitution. Modifies A and B inplace!
|
||||
|
||||
Args:
|
||||
A: row major matrix
|
||||
B: vector of floats or row major matrix
|
||||
|
||||
"""
|
||||
num = len(A)
|
||||
try:
|
||||
b_col_count = len(B[0])
|
||||
except TypeError:
|
||||
b_col_count = 1
|
||||
|
||||
for i in range(0, num):
|
||||
# Search for maximum in this column
|
||||
max_element = abs(A[i][i])
|
||||
max_row = i
|
||||
for row in range(i + 1, num):
|
||||
value = abs(A[row][i])
|
||||
if value > max_element:
|
||||
max_element = value
|
||||
max_row = row
|
||||
|
||||
# Swap maximum row with current row
|
||||
A[max_row], A[i] = A[i], A[max_row]
|
||||
B[max_row], B[i] = B[i], B[max_row]
|
||||
|
||||
# Make all rows below this one 0 in current column
|
||||
for row in range(i + 1, num):
|
||||
c = -A[row][i] / A[i][i]
|
||||
for col in range(i, num):
|
||||
if i == col:
|
||||
A[row][col] = 0
|
||||
else:
|
||||
A[row][col] += c * A[i][col]
|
||||
if b_col_count == 1:
|
||||
B[row] += c * B[i]
|
||||
else:
|
||||
for col in range(b_col_count):
|
||||
B[row][col] += c * B[i][col]
|
||||
|
||||
|
||||
def _backsubstitution(A: MatrixData, B: list[float]) -> list[float]:
|
||||
"""Solve equation A . x = B for an upper triangular matrix A by
|
||||
backsubstitution.
|
||||
|
||||
Args:
|
||||
A: row major matrix
|
||||
B: vector of floats
|
||||
|
||||
"""
|
||||
num = len(A)
|
||||
x = [0.0] * num
|
||||
for i in range(num - 1, -1, -1):
|
||||
x[i] = B[i] / A[i][i]
|
||||
for row in range(i - 1, -1, -1):
|
||||
B[row] -= A[row][i] * x[i]
|
||||
return x
|
||||
|
||||
|
||||
def gauss_jordan_solver(
|
||||
A: MatrixData | NDArray, B: MatrixData | NDArray
|
||||
) -> tuple[Matrix, Matrix]:
|
||||
"""Solves the linear equation system given by a nxn Matrix A . x = B,
|
||||
right-hand side quantities as nxm Matrix B by the `Gauss-Jordan`_ algorithm,
|
||||
which is the slowest of all, but it is very reliable. Returns a copy of the
|
||||
modified input matrix `A` and the result matrix `x`.
|
||||
|
||||
Internally used for matrix inverse calculation.
|
||||
|
||||
Args:
|
||||
A: matrix [[a11, a12, ..., a1n], [a21, a22, ..., a2n], [a21, a22, ..., a2n],
|
||||
... [an1, an2, ..., ann]]
|
||||
B: matrix [[b11, b12, ..., b1m], [b21, b22, ..., b2m], ... [bn1, bn2, ..., bnm]]
|
||||
|
||||
Returns:
|
||||
2-tuple of :class:`Matrix` objects
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
# copy input data
|
||||
matrix_a = copy_float_matrix(A)
|
||||
matrix_b = copy_float_matrix(B)
|
||||
|
||||
n = len(matrix_a)
|
||||
m = len(matrix_b[0])
|
||||
|
||||
if len(matrix_a[0]) != n:
|
||||
raise ValueError("A square nxn matrix A is required.")
|
||||
if len(matrix_b) != n:
|
||||
raise ValueError("Row count of matrices A and B has to match.")
|
||||
|
||||
icol = 0
|
||||
irow = 0
|
||||
col_indices = [0] * n
|
||||
row_indices = [0] * n
|
||||
ipiv = [0] * n
|
||||
|
||||
for i in range(n):
|
||||
big = 0.0
|
||||
for j in range(n):
|
||||
if ipiv[j] != 1:
|
||||
for k in range(n):
|
||||
if ipiv[k] == 0:
|
||||
if abs(matrix_a[j][k]) >= big:
|
||||
big = abs(matrix_a[j][k])
|
||||
irow = j
|
||||
icol = k
|
||||
|
||||
ipiv[icol] += 1
|
||||
if irow != icol:
|
||||
matrix_a[irow], matrix_a[icol] = matrix_a[icol], matrix_a[irow]
|
||||
matrix_b[irow], matrix_b[icol] = matrix_b[icol], matrix_b[irow]
|
||||
|
||||
row_indices[i] = irow
|
||||
col_indices[i] = icol
|
||||
|
||||
pivinv = 1.0 / matrix_a[icol][icol]
|
||||
matrix_a[icol][icol] = 1.0
|
||||
matrix_a[icol] = [v * pivinv for v in matrix_a[icol]]
|
||||
matrix_b[icol] = [v * pivinv for v in matrix_b[icol]]
|
||||
for row in range(n):
|
||||
if row == icol:
|
||||
continue
|
||||
dum = matrix_a[row][icol]
|
||||
matrix_a[row][icol] = 0.0
|
||||
for col in range(n):
|
||||
matrix_a[row][col] -= matrix_a[icol][col] * dum
|
||||
for col in range(m):
|
||||
matrix_b[row][col] -= matrix_b[icol][col] * dum
|
||||
|
||||
for i in range(n - 1, -1, -1):
|
||||
irow = row_indices[i]
|
||||
icol = col_indices[i]
|
||||
if irow != icol:
|
||||
for _row in matrix_a:
|
||||
_row[irow], _row[icol] = _row[icol], _row[irow]
|
||||
return Matrix(matrix=matrix_a), Matrix(matrix=matrix_b)
|
||||
|
||||
|
||||
def gauss_jordan_inverse(A: MatrixData) -> Matrix:
|
||||
"""Returns the inverse of matrix `A` as :class:`Matrix` object.
|
||||
|
||||
.. hint::
|
||||
|
||||
For small matrices (n<10) is this function faster than
|
||||
LUDecomposition(m).inverse() and as fast even if the decomposition is
|
||||
already done.
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
if isinstance(A, Matrix):
|
||||
matrix_a = A.matrix
|
||||
else:
|
||||
matrix_a = list(A)
|
||||
nrows = len(matrix_a)
|
||||
return gauss_jordan_solver(matrix_a, list(repeat([0.0], nrows)))[0]
|
||||
|
||||
|
||||
class LUDecomposition(Solver):
|
||||
"""Represents a `LU decomposition`_ matrix of A, raise :class:`ZeroDivisionError`
|
||||
for a singular matrix.
|
||||
|
||||
This algorithm is a little bit faster than the `Gauss-Elimination`_
|
||||
algorithm using CPython and much faster when using pypy.
|
||||
|
||||
The :attr:`LUDecomposition.matrix` attribute gives access to the matrix data
|
||||
as list of rows like in the :class:`Matrix` class, and the :attr:`LUDecomposition.index`
|
||||
attribute gives access to the swapped row indices.
|
||||
|
||||
Args:
|
||||
A: matrix [[a11, a12, ..., a1n], [a21, a22, ..., a2n], [a21, a22, ..., a2n],
|
||||
... [an1, an2, ..., ann]]
|
||||
|
||||
raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("matrix", "index", "_det")
|
||||
|
||||
def __init__(self, A: MatrixData | NDArray):
|
||||
lu: MatrixData = copy_float_matrix(A)
|
||||
n: int = len(lu)
|
||||
det: float = 1.0
|
||||
index: list[int] = []
|
||||
|
||||
# find max value for each row, raises ZeroDivisionError for singular matrix!
|
||||
scaling: list[float] = [1.0 / max(abs(v) for v in row) for row in lu]
|
||||
|
||||
for k in range(n):
|
||||
big: float = 0.0
|
||||
imax: int = k
|
||||
for i in range(k, n):
|
||||
temp: float = scaling[i] * abs(lu[i][k])
|
||||
if temp > big:
|
||||
big = temp
|
||||
imax = i
|
||||
|
||||
if k != imax:
|
||||
for col in range(n):
|
||||
temp = lu[imax][col]
|
||||
lu[imax][col] = lu[k][col]
|
||||
lu[k][col] = temp
|
||||
|
||||
det = -det
|
||||
scaling[imax] = scaling[k]
|
||||
|
||||
index.append(imax)
|
||||
for i in range(k + 1, n):
|
||||
temp = lu[i][k] / lu[k][k]
|
||||
lu[i][k] = temp
|
||||
for j in range(k + 1, n):
|
||||
lu[i][j] -= temp * lu[k][j]
|
||||
|
||||
self.index: list[int] = index
|
||||
self.matrix: MatrixData = lu
|
||||
self._det: float = det
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.matrix)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__} {reprlib.repr(self.matrix)}"
|
||||
|
||||
@property
|
||||
def nrows(self) -> int:
|
||||
"""Count of matrix rows (and cols)."""
|
||||
return len(self.matrix)
|
||||
|
||||
def solve_vector(self, B: Iterable[float]) -> list[float]:
|
||||
"""Solves the linear equation system given by the nxn Matrix A . x = B,
|
||||
right-hand side quantities as vector B with n elements.
|
||||
|
||||
Args:
|
||||
B: vector [b1, b2, ..., bn]
|
||||
|
||||
Returns:
|
||||
vector as list of floats
|
||||
|
||||
"""
|
||||
X: list[float] = [float(v) for v in B]
|
||||
lu: MatrixData = self.matrix
|
||||
index: list[int] = self.index
|
||||
n: int = self.nrows
|
||||
ii: int = 0
|
||||
|
||||
if len(X) != n:
|
||||
raise ValueError(
|
||||
"Item count of vector B has to be equal to matrix row count."
|
||||
)
|
||||
|
||||
for i in range(n):
|
||||
ip: int = index[i]
|
||||
sum_: float = X[ip]
|
||||
X[ip] = X[i]
|
||||
if ii != 0:
|
||||
for j in range(ii - 1, i):
|
||||
sum_ -= lu[i][j] * X[j]
|
||||
elif sum_ != 0.0:
|
||||
ii = i + 1
|
||||
X[i] = sum_
|
||||
|
||||
for row in range(n - 1, -1, -1):
|
||||
sum_ = X[row]
|
||||
for col in range(row + 1, n):
|
||||
sum_ -= lu[row][col] * X[col]
|
||||
X[row] = sum_ / lu[row][row]
|
||||
return X
|
||||
|
||||
def solve_matrix(self, B: MatrixData | NDArray) -> Matrix:
|
||||
"""Solves the linear equation system given by the nxn Matrix A . x = B,
|
||||
right-hand side quantities as nxm Matrix B.
|
||||
|
||||
Args:
|
||||
B: matrix [[b11, b12, ..., b1m], [b21, b22, ..., b2m],
|
||||
... [bn1, bn2, ..., bnm]]
|
||||
|
||||
Returns:
|
||||
matrix as :class:`Matrix` object
|
||||
|
||||
"""
|
||||
if not isinstance(B, Matrix):
|
||||
matrix_b = Matrix(matrix=[list(row) for row in B])
|
||||
else:
|
||||
matrix_b = cast(Matrix, B)
|
||||
|
||||
if matrix_b.nrows != self.nrows:
|
||||
raise ValueError("Row count of self and matrix B has to match.")
|
||||
|
||||
return Matrix(
|
||||
matrix=[self.solve_vector(col) for col in matrix_b.cols()]
|
||||
).transpose()
|
||||
|
||||
def inverse(self) -> Matrix:
|
||||
"""Returns the inverse of matrix as :class:`Matrix` object,
|
||||
raise :class:`ZeroDivisionError` for a singular matrix.
|
||||
|
||||
"""
|
||||
return self.solve_matrix(Matrix.identity(shape=(self.nrows, self.nrows)).matrix)
|
||||
|
||||
def determinant(self) -> float:
|
||||
"""Returns the determinant of matrix, raises :class:`ZeroDivisionError`
|
||||
if matrix is singular.
|
||||
|
||||
"""
|
||||
det: float = self._det
|
||||
lu: MatrixData = self.matrix
|
||||
for i in range(self.nrows):
|
||||
det *= lu[i][i]
|
||||
return det
|
||||
@@ -0,0 +1,854 @@
|
||||
# Copyright (c) 2018-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterable,
|
||||
Tuple,
|
||||
List,
|
||||
Sequence,
|
||||
Any,
|
||||
cast,
|
||||
Optional,
|
||||
)
|
||||
from typing_extensions import TypeAlias
|
||||
import abc
|
||||
|
||||
from functools import lru_cache
|
||||
from itertools import repeat
|
||||
import math
|
||||
import reprlib
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from ezdxf.acc import USE_C_EXT
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Matrix",
|
||||
"Solver",
|
||||
"numpy_vector_solver",
|
||||
"numpy_matrix_solver",
|
||||
"NumpySolver",
|
||||
"tridiagonal_vector_solver",
|
||||
"tridiagonal_matrix_solver",
|
||||
"detect_banded_matrix",
|
||||
"compact_banded_matrix",
|
||||
"BandedMatrixLU",
|
||||
"banded_matrix",
|
||||
"quadratic_equation",
|
||||
"cubic_equation",
|
||||
"binomial_coefficient",
|
||||
]
|
||||
|
||||
|
||||
def zip_to_list(*args) -> Iterable[list]:
|
||||
for e in zip(*args): # returns immutable tuples
|
||||
yield list(e) # need mutable list
|
||||
|
||||
|
||||
MatrixData: TypeAlias = List[List[float]]
|
||||
FrozenMatrixData: TypeAlias = Tuple[Tuple[float, ...]]
|
||||
Shape: TypeAlias = Tuple[int, int]
|
||||
NDArray: TypeAlias = npt.NDArray[np.float64]
|
||||
|
||||
|
||||
def copy_float_matrix(A) -> MatrixData:
|
||||
if isinstance(A, Matrix):
|
||||
A = A.matrix
|
||||
return [[float(v) for v in row] for row in A]
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def binomial_coefficient(k: int, i: int) -> float:
|
||||
# (c) Onur Rauf Bingol <orbingol@gmail.com>, NURBS-Python, MIT-License
|
||||
"""Computes the binomial coefficient (denoted by `k choose i`).
|
||||
|
||||
Please see the following website for details:
|
||||
http://mathworld.wolfram.com/BinomialCoefficient.html
|
||||
|
||||
Args:
|
||||
k: size of the set of distinct elements
|
||||
i: size of the subsets
|
||||
|
||||
"""
|
||||
# Special case
|
||||
if i > k:
|
||||
return float(0)
|
||||
# Compute binomial coefficient
|
||||
k_fact: int = math.factorial(k)
|
||||
i_fact: int = math.factorial(i)
|
||||
k_i_fact: int = math.factorial(k - i)
|
||||
return float(k_fact / (k_i_fact * i_fact))
|
||||
|
||||
|
||||
class Matrix:
|
||||
"""Basic matrix implementation based :class:`numpy.ndarray`. Matrix data is stored in
|
||||
row major order, this means in a list of rows, where each row is a list of floats.
|
||||
|
||||
Initialization:
|
||||
|
||||
- Matrix(shape=(rows, cols)) ... new matrix filled with zeros
|
||||
- Matrix(matrix[, shape=(rows, cols)]) ... from copy of matrix and optional reshape
|
||||
- Matrix([[row_0], [row_1], ..., [row_n]]) ... from Iterable[Iterable[float]]
|
||||
- Matrix([a1, a2, ..., an], shape=(rows, cols)) ... from Iterable[float] and shape
|
||||
|
||||
.. versionchanged:: 1.2
|
||||
Implementation based on :class:`numpy.ndarray`.
|
||||
|
||||
Attributes:
|
||||
matrix: matrix data as :class:`numpy.ndarray`
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("matrix", "abs_tol")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: Any = None,
|
||||
shape: Optional[Shape] = None,
|
||||
matrix: Optional[MatrixData | NDArray] = None,
|
||||
):
|
||||
self.abs_tol: float = 1e-12
|
||||
self.matrix: NDArray = np.array((), dtype=np.float64)
|
||||
if matrix is not None:
|
||||
self.matrix = np.array(matrix, dtype=np.float64)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
if shape is not None:
|
||||
self.matrix = np.zeros(shape)
|
||||
else: # items is None, shape is None
|
||||
return
|
||||
elif isinstance(items, Matrix):
|
||||
if shape is None:
|
||||
shape = items.shape
|
||||
self.matrix = items.matrix.reshape(shape).copy()
|
||||
else:
|
||||
items = list(items)
|
||||
try:
|
||||
self.matrix = np.array([list(row) for row in items], dtype=np.float64)
|
||||
except TypeError:
|
||||
if shape is not None:
|
||||
self.matrix = np.array(list(items), dtype=np.float64).reshape(shape)
|
||||
|
||||
def __iter__(self) -> NDArray:
|
||||
return np.ravel(self.matrix)
|
||||
|
||||
def __copy__(self) -> "Matrix":
|
||||
m = Matrix(matrix=self.matrix.copy())
|
||||
m.abs_tol = self.abs_tol
|
||||
return m
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.matrix)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Matrix({reprlib.repr(self.matrix)})"
|
||||
|
||||
@staticmethod
|
||||
def reshape(items: Iterable[float], shape: Shape) -> Matrix:
|
||||
"""Returns a new matrix for iterable `items` in the configuration of
|
||||
`shape`.
|
||||
"""
|
||||
return Matrix(matrix=np.array(list(items), dtype=np.float64).reshape(shape))
|
||||
|
||||
@property
|
||||
def nrows(self) -> int:
|
||||
"""Count of matrix rows."""
|
||||
return self.matrix.shape[0]
|
||||
|
||||
@property
|
||||
def ncols(self) -> int:
|
||||
"""Count of matrix columns."""
|
||||
return self.matrix.shape[1]
|
||||
|
||||
@property
|
||||
def shape(self) -> Shape:
|
||||
"""Shape of matrix as (n, m) tuple for n rows and m columns."""
|
||||
return self.matrix.shape # type: ignore
|
||||
|
||||
def row(self, index: int) -> list[float]:
|
||||
"""Returns row `index` as list of floats."""
|
||||
return list(self.matrix[index])
|
||||
|
||||
def col(self, index: int) -> list[float]:
|
||||
"""Return column `index` as list of floats."""
|
||||
return list(self.matrix[:, index])
|
||||
|
||||
def diag(self, index: int) -> list[float]:
|
||||
"""Returns diagonal `index` as list of floats.
|
||||
|
||||
An `index` of 0 specifies the main diagonal, negative values
|
||||
specifies diagonals below the main diagonal and positive values
|
||||
specifies diagonals above the main diagonal.
|
||||
|
||||
e.g. given a 4x4 matrix:
|
||||
|
||||
- index 0 is [00, 11, 22, 33],
|
||||
- index -1 is [10, 21, 32] and
|
||||
- index +1 is [01, 12, 23]
|
||||
|
||||
"""
|
||||
return list(self.matrix.diagonal(index))
|
||||
|
||||
def rows(self) -> list[list[float]]:
|
||||
"""Return a list of all rows."""
|
||||
return list(list(r) for r in self.matrix)
|
||||
|
||||
def cols(self) -> list[list[float]]:
|
||||
"""Return a list of all columns."""
|
||||
return [list(self.col(i)) for i in range(self.ncols)]
|
||||
|
||||
def set_row(self, index: int, items: float | Iterable[float] = 1.0) -> None:
|
||||
"""Set row values to a fixed value or from an iterable of floats."""
|
||||
if isinstance(items, (float, int)):
|
||||
items = [float(items)] * self.ncols
|
||||
items = list(items)
|
||||
if len(items) != self.ncols:
|
||||
raise ValueError("Invalid item count")
|
||||
self.matrix[index] = items
|
||||
|
||||
def set_col(self, index: int, items: float | Iterable[float] = 1.0) -> None:
|
||||
"""Set column values to a fixed value or from an iterable of floats."""
|
||||
if isinstance(items, (float, int)):
|
||||
items = [float(items)] * self.nrows
|
||||
self.matrix[:, index] = list(items)
|
||||
|
||||
def set_diag(self, index: int = 0, items: float | Iterable[float] = 1.0) -> None:
|
||||
"""Set diagonal values to a fixed value or from an iterable of floats.
|
||||
|
||||
An `index` of ``0`` specifies the main diagonal, negative values
|
||||
specifies diagonals below the main diagonal and positive values
|
||||
specifies diagonals above the main diagonal.
|
||||
|
||||
e.g. given a 4x4 matrix:
|
||||
index ``0`` is [00, 11, 22, 33],
|
||||
index ``-1`` is [10, 21, 32] and
|
||||
index ``+1`` is [01, 12, 23]
|
||||
|
||||
"""
|
||||
if isinstance(items, (float, int)):
|
||||
items = repeat(float(items))
|
||||
|
||||
col_offset: int = max(index, 0)
|
||||
row_offset: int = abs(min(index, 0))
|
||||
|
||||
for index, value in zip(range(max(self.nrows, self.ncols)), items):
|
||||
try:
|
||||
self.matrix[index + row_offset, index + col_offset] = value
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def identity(cls, shape: Shape) -> Matrix:
|
||||
"""Returns the identity matrix for configuration `shape`."""
|
||||
m = Matrix(shape=shape)
|
||||
m.set_diag(0, 1.0)
|
||||
return m
|
||||
|
||||
def append_row(self, items: Sequence[float]) -> None:
|
||||
"""Append a row to the matrix."""
|
||||
if self.matrix.size == 0:
|
||||
self.matrix = np.array([items], dtype=np.float64)
|
||||
elif len(items) == self.ncols:
|
||||
self.matrix = np.r_[self.matrix, items]
|
||||
else:
|
||||
raise ValueError("Invalid item count.")
|
||||
|
||||
def append_col(self, items: Sequence[float]) -> None:
|
||||
"""Append a column to the matrix."""
|
||||
if self.matrix.size == 0:
|
||||
self.matrix = np.array([[item] for item in items], dtype=np.float64)
|
||||
elif len(items) == self.nrows:
|
||||
self.matrix = np.c_[self.matrix, items]
|
||||
else:
|
||||
raise ValueError("Invalid item count.")
|
||||
|
||||
def freeze(self) -> Matrix:
|
||||
"""Returns a frozen matrix, all data is stored in immutable tuples."""
|
||||
m = self.__copy__()
|
||||
m.matrix.flags.writeable = False
|
||||
return m
|
||||
|
||||
def __getitem__(self, item: tuple[int, int]) -> float:
|
||||
"""Get value by (row, col) index tuple, fancy slicing as known from
|
||||
numpy is not supported.
|
||||
|
||||
"""
|
||||
return float(self.matrix[item])
|
||||
|
||||
def __setitem__(self, item: tuple[int, int], value: float):
|
||||
"""Set value by (row, col) index tuple, fancy slicing as known from
|
||||
numpy is not supported.
|
||||
|
||||
"""
|
||||
self.matrix[item] = value
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Returns ``True`` if matrices are equal."""
|
||||
if not isinstance(other, Matrix):
|
||||
raise TypeError("Matrix class required.")
|
||||
if self.shape != other.shape:
|
||||
raise TypeError("Matrices have different shapes.")
|
||||
return bool(np.all(self.matrix == other.matrix))
|
||||
|
||||
def isclose(self, other: object) -> bool:
|
||||
"""Returns ``True`` if matrices are close to equal, tolerance value for
|
||||
comparison is adjustable by the attribute :attr:`Matrix.abs_tol`.
|
||||
|
||||
"""
|
||||
if not isinstance(other, Matrix):
|
||||
raise TypeError("Matrix class required.")
|
||||
if self.shape != other.shape:
|
||||
raise TypeError("Matrices have different shapes.")
|
||||
return bool(np.all(np.isclose(self.matrix, other.matrix, atol=self.abs_tol)))
|
||||
|
||||
def __mul__(self, other: Matrix | float) -> Matrix:
|
||||
"""Matrix multiplication by another matrix or a float, returns a new
|
||||
matrix.
|
||||
|
||||
"""
|
||||
if isinstance(other, Matrix):
|
||||
return Matrix(matrix=np.matmul(self.matrix, other.matrix))
|
||||
else:
|
||||
matrix = Matrix(matrix=self.matrix * float(other)) # type: ignore
|
||||
return matrix
|
||||
|
||||
__imul__ = __mul__
|
||||
|
||||
def __add__(self, other: Matrix | float) -> Matrix:
|
||||
"""Matrix addition by another matrix or a float, returns a new matrix."""
|
||||
if isinstance(other, Matrix):
|
||||
return Matrix(matrix=self.matrix + other.matrix) # type: ignore
|
||||
else:
|
||||
return Matrix(matrix=self.matrix + float(other)) # type: ignore
|
||||
|
||||
__iadd__ = __add__
|
||||
|
||||
def __sub__(self, other: Matrix | float) -> Matrix:
|
||||
"""Matrix subtraction by another matrix or a float, returns a new
|
||||
matrix.
|
||||
|
||||
"""
|
||||
if isinstance(other, Matrix):
|
||||
return Matrix(matrix=self.matrix - other.matrix) # type: ignore
|
||||
else:
|
||||
return Matrix(matrix=self.matrix - float(other)) # type: ignore
|
||||
|
||||
__isub__ = __sub__
|
||||
|
||||
def transpose(self) -> Matrix:
|
||||
"""Returns a new transposed matrix."""
|
||||
return Matrix(matrix=self.matrix.T)
|
||||
|
||||
def inverse(self) -> Matrix:
|
||||
"""Returns inverse of matrix as new object."""
|
||||
if self.nrows != self.ncols:
|
||||
raise TypeError("Inverse of non-square matrix not supported.")
|
||||
try:
|
||||
return Matrix(matrix=np.linalg.inv(self.matrix)) # type: ignore
|
||||
except np.linalg.LinAlgError:
|
||||
raise ZeroDivisionError
|
||||
|
||||
def determinant(self) -> float:
|
||||
"""Returns determinant of matrix, raises :class:`ZeroDivisionError`
|
||||
if matrix is singular.
|
||||
|
||||
"""
|
||||
return float(np.linalg.det(self.matrix))
|
||||
|
||||
|
||||
class Solver(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def solve_matrix(self, B: MatrixData | NDArray) -> Matrix:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def solve_vector(self, B: Iterable[float]) -> list[float]:
|
||||
...
|
||||
|
||||
|
||||
def quadratic_equation(a: float, b: float, c: float, abs_tol=1e-12) -> Sequence[float]:
|
||||
"""Returns the solution for the quadratic equation ``a*x^2 + b*x + c = 0``.
|
||||
|
||||
Returns 0-2 solutions as a tuple of floats.
|
||||
"""
|
||||
if abs(a) < abs_tol:
|
||||
if abs(b) < abs_tol:
|
||||
return (-c,)
|
||||
return (-c / b,)
|
||||
try:
|
||||
discriminant = math.sqrt(b**2 - 4 * a * c)
|
||||
except ValueError: # domain error, sqrt of a negative number
|
||||
return tuple()
|
||||
return ((-b + discriminant) / (2.0 * a)), ((-b - discriminant) / (2.0 * a))
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def cubic_equation(a: float, b: float, c: float, d: float) -> Sequence[float]:
|
||||
"""Returns the solution for the cubic equation ``a*x^3 + b*x^2 + c*x + d = 0``.
|
||||
|
||||
Returns 0-3 solutions as a tuple of floats.
|
||||
"""
|
||||
if abs(a) < 1e-12:
|
||||
try:
|
||||
return quadratic_equation(b, c, d)
|
||||
except ArithmeticError: # complex solution
|
||||
return tuple()
|
||||
A = b / a
|
||||
B = c / a
|
||||
C = d / a
|
||||
AA = A * A
|
||||
A3 = A / 3.0
|
||||
|
||||
Q = (3.0 * B - AA) / 9.0
|
||||
R = (9.0 * A * B - 27.0 * C - 2.0 * (AA * A)) / 54.0
|
||||
QQQ = Q * Q * Q
|
||||
D = QQQ + (R * R) # polynomial discriminant
|
||||
|
||||
if D >= 0.0: # complex or duplicate roots
|
||||
sqrtD = math.sqrt(D)
|
||||
exp = 1.0 / 3.0
|
||||
S = math.copysign(1.0, R + sqrtD) * math.pow(abs(R + sqrtD), exp)
|
||||
T = math.copysign(1.0, R - sqrtD) * math.pow(abs(R - sqrtD), exp)
|
||||
ST = S + T
|
||||
if S - T: # is complex
|
||||
return (-A3 + ST,) # real root
|
||||
else:
|
||||
ST_2 = ST / 2.0
|
||||
return (
|
||||
-A3 + ST, # real root
|
||||
-A3 - ST_2, # real part of complex root
|
||||
-A3 - ST_2, # real part of complex root
|
||||
)
|
||||
|
||||
th = math.acos(R / math.sqrt(-QQQ))
|
||||
sqrtQ2 = math.sqrt(-Q) * 2.0
|
||||
return (
|
||||
sqrtQ2 * math.cos(th / 3.0) - A3,
|
||||
sqrtQ2 * math.cos((th + 2.0 * math.pi) / 3.0) - A3,
|
||||
sqrtQ2 * math.cos((th + 4.0 * math.pi) / 3.0) - A3,
|
||||
)
|
||||
|
||||
|
||||
def numpy_matrix_solver(A: MatrixData | NDArray, B: MatrixData | NDArray) -> Matrix:
|
||||
"""Solves the linear equation system given by a nxn Matrix A . x = B by the
|
||||
numpy.linalg.solve() function.
|
||||
|
||||
Args:
|
||||
A: matrix [[a11, a12, ..., a1n], [a21, a22, ..., a2n], ... [an1, an2, ..., ann]]
|
||||
B: matrix [[b11, b12, ..., b1m], [b21, b22, ..., b2m], ... [bn1, bn2, ..., bnm]]
|
||||
|
||||
Raises:
|
||||
numpy.linalg.LinAlgError: singular matrix
|
||||
|
||||
"""
|
||||
mat_A = np.array(A, dtype=np.float64)
|
||||
mat_B = np.array(B, dtype=np.float64)
|
||||
return Matrix(matrix=np.linalg.solve(mat_A, mat_B)) # type: ignore
|
||||
|
||||
|
||||
def numpy_vector_solver(A: MatrixData | NDArray, B: Iterable[float]) -> list[float]:
|
||||
"""Solves the linear equation system given by a nxn Matrix A . x = B,
|
||||
right-hand side quantities as vector B with n elements by the numpy.linalg.solve()
|
||||
function.
|
||||
|
||||
Args:
|
||||
A: matrix [[a11, a12, ..., a1n], [a21, a22, ..., a2n], ... [an1, an2, ..., ann]]
|
||||
B: vector [b1, b2, ..., bn]
|
||||
|
||||
Raises:
|
||||
numpy.linalg.LinAlgError: singular matrix
|
||||
|
||||
"""
|
||||
mat_A = np.array(A, dtype=np.float64)
|
||||
mat_B = np.array([[float(v)] for v in B], dtype=np.float64)
|
||||
return list(np.ravel(np.linalg.solve(mat_A, mat_B)))
|
||||
|
||||
|
||||
class NumpySolver(Solver):
|
||||
"""Replaces in v1.2 the :class:`LUDecomposition` solver."""
|
||||
|
||||
def __init__(self, A: MatrixData | NDArray) -> None:
|
||||
self.mat_A = np.array(A, dtype=np.float64)
|
||||
|
||||
def solve_matrix(self, B: MatrixData | NDArray) -> Matrix:
|
||||
"""
|
||||
Solves the linear equation system given by the nxn Matrix
|
||||
A . x = B, right-hand side quantities as nxm Matrix B.
|
||||
|
||||
Args:
|
||||
B: matrix [[b11, b12, ..., b1m], [b21, b22, ..., b2m],
|
||||
... [bn1, bn2, ..., bnm]]
|
||||
|
||||
Raises:
|
||||
numpy.linalg.LinAlgError: singular matrix
|
||||
|
||||
"""
|
||||
mat_B = np.array(B, dtype=np.float64)
|
||||
return Matrix(matrix=np.linalg.solve(self.mat_A, mat_B)) # type: ignore
|
||||
|
||||
def solve_vector(self, B: Iterable[float]) -> list[float]:
|
||||
"""Solves the linear equation system given by the nxn Matrix
|
||||
A . x = B, right-hand side quantities as vector B with n elements.
|
||||
|
||||
Args:
|
||||
B: vector [b1, b2, ..., bn]
|
||||
|
||||
Raises:
|
||||
numpy.linalg.LinAlgError: singular matrix
|
||||
|
||||
"""
|
||||
mat_B = np.array([[float(v)] for v in B], dtype=np.float64)
|
||||
return list(np.ravel(np.linalg.solve(self.mat_A, mat_B)))
|
||||
|
||||
|
||||
def tridiagonal_vector_solver(A: MatrixData, B: Iterable[float]) -> list[float]:
|
||||
"""Solves the linear equation system given by a tri-diagonal nxn Matrix
|
||||
A . x = B, right-hand side quantities as vector B. Matrix A is diagonal
|
||||
matrix defined by 3 diagonals [-1 (a), 0 (b), +1 (c)].
|
||||
|
||||
Note: a0 is not used but has to be present, cn-1 is also not used and must
|
||||
not be present.
|
||||
|
||||
If an :class:`ZeroDivisionError` exception occurs, the equation system can
|
||||
possibly be solved by :code:`BandedMatrixLU(A, 1, 1).solve_vector(B)`
|
||||
|
||||
Args:
|
||||
A: diagonal matrix [[a0..an-1], [b0..bn-1], [c0..cn-1]] ::
|
||||
|
||||
[[b0, c0, 0, 0, ...],
|
||||
[a1, b1, c1, 0, ...],
|
||||
[0, a2, b2, c2, ...],
|
||||
... ]
|
||||
|
||||
B: iterable of floats [[b1, b1, ..., bn]
|
||||
|
||||
Returns:
|
||||
list of floats
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
a, b, c = [list(v) for v in A]
|
||||
return _solve_tridiagonal_matrix(a, b, c, list(B))
|
||||
|
||||
|
||||
def tridiagonal_matrix_solver(
|
||||
A: MatrixData | NDArray, B: MatrixData | NDArray
|
||||
) -> Matrix:
|
||||
"""Solves the linear equation system given by a tri-diagonal nxn Matrix
|
||||
A . x = B, right-hand side quantities as nxm Matrix B. Matrix A is diagonal
|
||||
matrix defined by 3 diagonals [-1 (a), 0 (b), +1 (c)].
|
||||
|
||||
Note: a0 is not used but has to be present, cn-1 is also not used and must
|
||||
not be present.
|
||||
|
||||
If an :class:`ZeroDivisionError` exception occurs, the equation system
|
||||
can possibly be solved by :code:`BandedMatrixLU(A, 1, 1).solve_vector(B)`
|
||||
|
||||
Args:
|
||||
A: diagonal matrix [[a0..an-1], [b0..bn-1], [c0..cn-1]] ::
|
||||
|
||||
[[b0, c0, 0, 0, ...],
|
||||
[a1, b1, c1, 0, ...],
|
||||
[0, a2, b2, c2, ...],
|
||||
... ]
|
||||
|
||||
B: matrix [[b11, b12, ..., b1m],
|
||||
[b21, b22, ..., b2m],
|
||||
...
|
||||
[bn1, bn2, ..., bnm]]
|
||||
|
||||
Returns:
|
||||
matrix as :class:`Matrix` object
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
a, b, c = [list(v) for v in A]
|
||||
if not isinstance(B, Matrix):
|
||||
matrix_b = Matrix(matrix=[list(row) for row in B])
|
||||
else:
|
||||
matrix_b = cast(Matrix, B)
|
||||
if matrix_b.nrows != len(b):
|
||||
raise ValueError("Row count of matrices A and B has to match.")
|
||||
|
||||
return Matrix(
|
||||
matrix=[_solve_tridiagonal_matrix(a, b, c, col) for col in matrix_b.cols()]
|
||||
).transpose()
|
||||
|
||||
|
||||
def _solve_tridiagonal_matrix(
|
||||
a: list[float], b: list[float], c: list[float], r: list[float]
|
||||
) -> list[float]:
|
||||
"""Solves the linear equation system given by a tri-diagonal
|
||||
Matrix(a, b, c) . x = r.
|
||||
|
||||
Matrix configuration::
|
||||
|
||||
[[b0, c0, 0, 0, ...],
|
||||
[a1, b1, c1, 0, ...],
|
||||
[0, a2, b2, c2, ...],
|
||||
... ]
|
||||
|
||||
Args:
|
||||
a: lower diagonal [a0 .. an-1], a0 is not used but has to be present
|
||||
b: central diagonal [b0 .. bn-1]
|
||||
c: upper diagonal [c0 .. cn-1], cn-1 is not used and must not be present
|
||||
r: right-hand side quantities
|
||||
|
||||
Returns:
|
||||
vector x as list of floats
|
||||
|
||||
Raises:
|
||||
ZeroDivisionError: singular matrix
|
||||
|
||||
"""
|
||||
n: int = len(a)
|
||||
u: list[float] = [0.0] * n
|
||||
gam: list[float] = [0.0] * n
|
||||
bet: float = b[0]
|
||||
u[0] = r[0] / bet
|
||||
for j in range(1, n):
|
||||
gam[j] = c[j - 1] / bet
|
||||
bet = b[j] - a[j] * gam[j]
|
||||
u[j] = (r[j] - a[j] * u[j - 1]) / bet
|
||||
|
||||
for j in range((n - 2), -1, -1):
|
||||
u[j] -= gam[j + 1] * u[j + 1]
|
||||
return u
|
||||
|
||||
|
||||
def banded_matrix(A: Matrix, check_all=True) -> tuple[Matrix, int, int]:
|
||||
"""Transform matrix A into a compact banded matrix representation.
|
||||
Returns compact representation as :class:`Matrix` object and
|
||||
lower- and upper band count m1 and m2.
|
||||
|
||||
Args:
|
||||
A: input :class:`Matrix`
|
||||
check_all: check all diagonals if ``True`` or abort testing
|
||||
after first all zero diagonal if ``False``.
|
||||
|
||||
"""
|
||||
m1, m2 = detect_banded_matrix(A, check_all)
|
||||
m = compact_banded_matrix(A, m1, m2)
|
||||
return m, m1, m2
|
||||
|
||||
|
||||
def detect_banded_matrix(A: Matrix, check_all=True) -> tuple[int, int]:
|
||||
"""Returns lower- and upper band count m1 and m2.
|
||||
|
||||
Args:
|
||||
A: input :class:`Matrix`
|
||||
check_all: check all diagonals if ``True`` or abort testing
|
||||
after first all zero diagonal if ``False``.
|
||||
|
||||
"""
|
||||
|
||||
def detect_m2() -> int:
|
||||
m2: int = 0
|
||||
for d in range(1, A.ncols):
|
||||
if any(A.diag(d)):
|
||||
m2 = d
|
||||
elif not check_all:
|
||||
break
|
||||
return m2
|
||||
|
||||
def detect_m1() -> int:
|
||||
m1: int = 0
|
||||
for d in range(1, A.nrows):
|
||||
if any(A.diag(-d)):
|
||||
m1 = d
|
||||
elif not check_all:
|
||||
break
|
||||
return m1
|
||||
|
||||
return detect_m1(), detect_m2()
|
||||
|
||||
|
||||
def compact_banded_matrix(A: Matrix, m1: int, m2: int) -> Matrix:
|
||||
"""Returns compact banded matrix representation as :class:`Matrix` object.
|
||||
|
||||
Args:
|
||||
A: matrix to transform
|
||||
m1: lower band count, excluding main matrix diagonal
|
||||
m2: upper band count, excluding main matrix diagonal
|
||||
|
||||
"""
|
||||
if A.nrows != A.ncols:
|
||||
raise TypeError("Square matrix required.")
|
||||
|
||||
m = Matrix()
|
||||
|
||||
for d in range(m1, 0, -1):
|
||||
col = [0.0] * d
|
||||
col.extend(A.diag(-d))
|
||||
m.append_col(col)
|
||||
|
||||
m.append_col(A.diag(0))
|
||||
|
||||
for d in range(1, m2 + 1):
|
||||
col = A.diag(d)
|
||||
col.extend([0.0] * d)
|
||||
m.append_col(col)
|
||||
return m
|
||||
|
||||
|
||||
class BandedMatrixLU(Solver):
|
||||
"""Represents a LU decomposition of a compact banded matrix."""
|
||||
|
||||
def __init__(self, A: Matrix, m1: int, m2: int):
|
||||
lu_decompose = _lu_decompose
|
||||
if USE_C_EXT:
|
||||
# import error shows an installation issue
|
||||
from ezdxf.acc.np_support import lu_decompose # type: ignore
|
||||
|
||||
self.m1: int = int(m1)
|
||||
self.m2: int = int(m2)
|
||||
self.upper, self.lower, self.index = lu_decompose(A.matrix, self.m1, self.m2)
|
||||
|
||||
@property
|
||||
def nrows(self) -> int:
|
||||
"""Count of matrix rows."""
|
||||
return self.upper.shape[0]
|
||||
|
||||
def solve_vector(self, B: Iterable[float]) -> list[float]:
|
||||
"""Solves the linear equation system given by the banded nxn Matrix
|
||||
A . x = B, right-hand side quantities as vector B with n elements.
|
||||
|
||||
Args:
|
||||
B: vector [b1, b2, ..., bn]
|
||||
|
||||
Returns:
|
||||
vector as list of floats
|
||||
|
||||
"""
|
||||
|
||||
solve_vector_banded_matrix = _solve_vector_banded_matrix
|
||||
if USE_C_EXT:
|
||||
# import error shows an installation issue
|
||||
from ezdxf.acc.np_support import solve_vector_banded_matrix # type: ignore
|
||||
|
||||
x: NDArray = np.array(B, dtype=np.float64)
|
||||
if len(x) != self.nrows:
|
||||
raise ValueError(
|
||||
"Item count of vector B has to be equal to matrix row count."
|
||||
)
|
||||
return list(
|
||||
solve_vector_banded_matrix(
|
||||
x, self.upper, self.lower, self.index, self.m1, self.m2
|
||||
)
|
||||
)
|
||||
|
||||
def solve_matrix(self, B: MatrixData | NDArray) -> Matrix:
|
||||
"""
|
||||
Solves the linear equation system given by the banded nxn Matrix
|
||||
A . x = B, right-hand side quantities as nxm Matrix B.
|
||||
|
||||
Args:
|
||||
B: matrix [[b11, b12, ..., b1m], [b21, b22, ..., b2m],
|
||||
... [bn1, bn2, ..., bnm]]
|
||||
|
||||
Returns:
|
||||
matrix as :class:`Matrix` object
|
||||
|
||||
"""
|
||||
matrix_b = Matrix(matrix=B)
|
||||
if matrix_b.nrows != self.nrows:
|
||||
raise ValueError("Row count of self and matrix B has to match.")
|
||||
|
||||
return Matrix(
|
||||
matrix=[self.solve_vector(col) for col in matrix_b.cols()]
|
||||
).transpose()
|
||||
|
||||
|
||||
def _lu_decompose(A: NDArray, m1: int, m2: int) -> tuple[NDArray, NDArray, NDArray]:
|
||||
# upper triangle of LU decomposition
|
||||
upper: NDArray = np.array(A, dtype=np.float64)
|
||||
|
||||
# lower triangle of LU decomposition
|
||||
n: int = upper.shape[0]
|
||||
lower: NDArray = np.zeros((n, m1), dtype=np.float64)
|
||||
index: NDArray = np.zeros((n,), dtype=np.int64)
|
||||
|
||||
mm: int = m1 + m2 + 1
|
||||
l: int = m1
|
||||
for i in range(m1):
|
||||
for j in range(m1 - i, mm):
|
||||
upper[i][j - l] = upper[i][j]
|
||||
l -= 1
|
||||
for j in range(mm - l - 1, mm):
|
||||
upper[i][j] = 0.0
|
||||
|
||||
l = m1
|
||||
for k in range(n):
|
||||
dum = upper[k][0]
|
||||
i = k
|
||||
if l < n:
|
||||
l += 1
|
||||
for j in range(k + 1, l):
|
||||
if abs(upper[j][0]) > abs(dum):
|
||||
dum = upper[j][0]
|
||||
i = j
|
||||
index[k] = i + 1
|
||||
if i != k:
|
||||
for j in range(mm):
|
||||
upper[k][j], upper[i][j] = upper[i][j], upper[k][j]
|
||||
|
||||
for i in range(k + 1, l):
|
||||
dum = upper[i][0] / upper[k][0]
|
||||
lower[k][i - k - 1] = dum
|
||||
for j in range(1, mm):
|
||||
upper[i][j - 1] = upper[i][j] - dum * upper[k][j]
|
||||
upper[i][mm - 1] = 0.0
|
||||
return upper, lower, index
|
||||
|
||||
|
||||
def _solve_vector_banded_matrix(
|
||||
x: NDArray,
|
||||
upper: NDArray,
|
||||
lower: NDArray,
|
||||
index: NDArray,
|
||||
m1: int,
|
||||
m2: int,
|
||||
) -> NDArray:
|
||||
"""Solves the linear equation system given by the banded nxn Matrix
|
||||
A . x = B, right-hand side quantities as vector B with n elements.
|
||||
|
||||
Args:
|
||||
B: vector [b1, b2, ..., bn]
|
||||
|
||||
Returns:
|
||||
vector as list of floats
|
||||
|
||||
"""
|
||||
n: int = upper.shape[0]
|
||||
if x.shape[0] != n:
|
||||
raise ValueError("Item count of vector B has to be equal to matrix row count.")
|
||||
|
||||
al: NDArray = lower
|
||||
au: NDArray = upper
|
||||
mm: int = m1 + m2 + 1
|
||||
l: int = m1
|
||||
for k in range(n):
|
||||
j = index[k] - 1
|
||||
if j != k:
|
||||
x[k], x[j] = x[j], x[k]
|
||||
if l < n:
|
||||
l += 1
|
||||
for j in range(k + 1, l):
|
||||
x[j] -= al[k][j - k - 1] * x[k]
|
||||
|
||||
l = 1
|
||||
for i in range(n - 1, -1, -1):
|
||||
dum = x[i]
|
||||
for k in range(1, l):
|
||||
dum -= au[i][k] * x[k + i]
|
||||
x[i] = dum / au[i][0]
|
||||
if l < mm:
|
||||
l += 1
|
||||
|
||||
return x
|
||||
@@ -0,0 +1,314 @@
|
||||
# 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
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) 2019-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable
|
||||
from ezdxf.math import Vec2, UVec
|
||||
from ezdxf.math.line import ConstructionRay, ParallelRaysError
|
||||
|
||||
|
||||
__all__ = ["offset_vertices_2d"]
|
||||
|
||||
|
||||
def offset_vertices_2d(
|
||||
vertices: Iterable[UVec], offset: float, closed: bool = False
|
||||
) -> Iterable[Vec2]:
|
||||
"""Yields vertices of the offset line to the shape defined by `vertices`.
|
||||
The source shape consist of straight segments and is located in the xy-plane,
|
||||
the z-axis of input vertices is ignored. Takes closed shapes into account if
|
||||
argument `closed` is ``True``, which yields intersection of first and last
|
||||
offset segment as first vertex for a closed shape. For closed shapes the
|
||||
first and last vertex can be equal, else an implicit closing segment from
|
||||
last to first vertex is added. A shape with equal first and last vertex is
|
||||
not handled automatically as closed shape.
|
||||
|
||||
.. warning::
|
||||
|
||||
Adjacent collinear segments in `opposite` directions, same as a turn by
|
||||
180 degree (U-turn), leads to unexpected results.
|
||||
|
||||
Args:
|
||||
vertices: source shape defined by vertices
|
||||
offset: line offset perpendicular to direction of shape segments defined
|
||||
by vertices order, offset > ``0`` is 'left' of line segment,
|
||||
offset < ``0`` is 'right' of line segment
|
||||
closed: ``True`` to handle as closed shape
|
||||
|
||||
"""
|
||||
_vertices = Vec2.list(vertices)
|
||||
if len(_vertices) < 2:
|
||||
raise ValueError("2 or more vertices required.")
|
||||
|
||||
if closed and not _vertices[0].isclose(_vertices[-1]):
|
||||
# append first vertex as last vertex to close shape
|
||||
_vertices.append(_vertices[0])
|
||||
|
||||
# create offset segments
|
||||
offset_segments = list()
|
||||
for start, end in zip(_vertices[:-1], _vertices[1:]):
|
||||
offset_vec = (end - start).orthogonal().normalize(offset)
|
||||
offset_segments.append((start + offset_vec, end + offset_vec))
|
||||
|
||||
if closed: # insert last segment also as first segment
|
||||
offset_segments.insert(0, offset_segments[-1])
|
||||
|
||||
# first offset vertex = start point of first segment for open shapes
|
||||
if not closed:
|
||||
yield offset_segments[0][0]
|
||||
|
||||
# yield intersection points of offset_segments
|
||||
if len(offset_segments) > 1:
|
||||
for (start1, end1), (start2, end2) in zip(
|
||||
offset_segments[:-1], offset_segments[1:]
|
||||
):
|
||||
try: # the usual case
|
||||
yield ConstructionRay(start1, end1).intersect(
|
||||
ConstructionRay(start2, end2)
|
||||
)
|
||||
except ParallelRaysError: # collinear segments
|
||||
yield end1
|
||||
if not end1.isclose(start2): # it's an U-turn (180 deg)
|
||||
# creates an additional vertex!
|
||||
yield start2
|
||||
|
||||
# last offset vertex = end point of last segment for open shapes
|
||||
if not closed:
|
||||
yield offset_segments[-1][1]
|
||||
@@ -0,0 +1,255 @@
|
||||
# Copyright (c) 2020-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Sequence
|
||||
import math
|
||||
from ezdxf.math import Vec3
|
||||
from .bezier_interpolation import (
|
||||
tangents_cubic_bezier_interpolation,
|
||||
cubic_bezier_interpolation,
|
||||
)
|
||||
from .construct2d import circle_radius_3p
|
||||
|
||||
__all__ = [
|
||||
"estimate_tangents",
|
||||
"estimate_end_tangent_magnitude",
|
||||
"create_t_vector",
|
||||
"chord_length",
|
||||
]
|
||||
|
||||
|
||||
def create_t_vector(fit_points: list[Vec3], method: str) -> list[float]:
|
||||
if method == "uniform":
|
||||
return uniform_t_vector(len(fit_points))
|
||||
elif method in ("distance", "chord"):
|
||||
return distance_t_vector(fit_points)
|
||||
elif method in ("centripetal", "sqrt_chord"):
|
||||
return centripetal_t_vector(fit_points)
|
||||
elif method == "arc":
|
||||
return arc_t_vector(fit_points)
|
||||
else:
|
||||
raise ValueError("Unknown method: {}".format(method))
|
||||
|
||||
|
||||
def uniform_t_vector(length: int) -> list[float]:
|
||||
n = float(length - 1)
|
||||
return [t / n for t in range(length)]
|
||||
|
||||
|
||||
def distance_t_vector(fit_points: list[Vec3]) -> list[float]:
|
||||
return _normalize_distances(list(linear_distances(fit_points)))
|
||||
|
||||
|
||||
def centripetal_t_vector(fit_points: list[Vec3]) -> list[float]:
|
||||
distances = [
|
||||
math.sqrt(p1.distance(p2)) for p1, p2 in zip(fit_points, fit_points[1:])
|
||||
]
|
||||
return _normalize_distances(distances)
|
||||
|
||||
|
||||
def _normalize_distances(distances: Sequence[float]) -> list[float]:
|
||||
total_length = sum(distances)
|
||||
if abs(total_length) <= 1e-12:
|
||||
return []
|
||||
params: list[float] = [0.0]
|
||||
s = 0.0
|
||||
for d in distances[:-1]:
|
||||
s += d
|
||||
params.append(s / total_length)
|
||||
params.append(1.0)
|
||||
return params
|
||||
|
||||
|
||||
def linear_distances(points: Iterable[Vec3]) -> Iterable[float]:
|
||||
prev = None
|
||||
for p in points:
|
||||
if prev is None:
|
||||
prev = p
|
||||
continue
|
||||
yield prev.distance(p)
|
||||
prev = p
|
||||
|
||||
|
||||
def chord_length(points: Iterable[Vec3]) -> float:
|
||||
return sum(linear_distances(points))
|
||||
|
||||
|
||||
def arc_t_vector(fit_points: list[Vec3]) -> list[float]:
|
||||
distances = list(arc_distances(fit_points))
|
||||
return _normalize_distances(distances)
|
||||
|
||||
|
||||
def arc_distances(fit_points: list[Vec3]) -> Iterable[float]:
|
||||
p = fit_points
|
||||
|
||||
def _radii() -> Iterable[float]:
|
||||
for i in range(len(p) - 2):
|
||||
try:
|
||||
radius = circle_radius_3p(p[i], p[i + 1], p[i + 2])
|
||||
except ZeroDivisionError:
|
||||
radius = 0.0
|
||||
yield radius
|
||||
|
||||
r: list[float] = list(_radii())
|
||||
if len(r) == 0:
|
||||
return
|
||||
r.append(r[-1]) # 2x last radius
|
||||
for k in range(0, len(p) - 1):
|
||||
distance = (p[k + 1] - p[k]).magnitude
|
||||
rk = r[k]
|
||||
if math.isclose(rk, 0):
|
||||
yield distance
|
||||
else:
|
||||
yield math.asin(distance / 2.0 / rk) * 2.0 * rk
|
||||
|
||||
|
||||
def estimate_tangents(
|
||||
points: list[Vec3], method: str = "5-points", normalize=True
|
||||
) -> list[Vec3]:
|
||||
"""Estimate tangents for curve defined by given fit points.
|
||||
Calculated tangents are normalized (unit-vectors).
|
||||
|
||||
Available tangent estimation methods:
|
||||
|
||||
- "3-points": 3 point interpolation
|
||||
- "5-points": 5 point interpolation
|
||||
- "bezier": tangents from an interpolated cubic bezier curve
|
||||
- "diff": finite difference
|
||||
|
||||
Args:
|
||||
points: start-, end- and passing points of curve
|
||||
method: tangent estimation method
|
||||
normalize: normalize tangents if ``True``
|
||||
|
||||
Returns:
|
||||
tangents as list of :class:`Vec3` objects
|
||||
|
||||
"""
|
||||
method = method.lower()
|
||||
if method.startswith("bez"):
|
||||
return tangents_cubic_bezier_interpolation(points, normalize=normalize)
|
||||
elif method.startswith("3-p"):
|
||||
return tangents_3_point_interpolation(points, normalize=normalize)
|
||||
elif method.startswith("5-p"):
|
||||
return tangents_5_point_interpolation(points, normalize=normalize)
|
||||
elif method.startswith("dif"):
|
||||
return finite_difference_interpolation(points, normalize=normalize)
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
|
||||
|
||||
def estimate_end_tangent_magnitude(
|
||||
points: list[Vec3], method: str = "chord"
|
||||
) -> tuple[float, float]:
|
||||
"""Estimate tangent magnitude of start- and end tangents.
|
||||
|
||||
Available estimation methods:
|
||||
|
||||
- "chord": total chord length, curve approximation by straight segments
|
||||
- "arc": total arc length, curve approximation by arcs
|
||||
- "bezier-n": total length from cubic bezier curve approximation, n
|
||||
segments per section
|
||||
|
||||
Args:
|
||||
points: start-, end- and passing points of curve
|
||||
method: tangent magnitude estimation method
|
||||
|
||||
"""
|
||||
if method == "chord":
|
||||
total_length = sum(p0.distance(p1) for p0, p1 in zip(points, points[1:]))
|
||||
return total_length, total_length
|
||||
elif method == "arc":
|
||||
total_length = sum(arc_distances(points))
|
||||
return total_length, total_length
|
||||
elif method.startswith("bezier-"):
|
||||
count = int(method[7:])
|
||||
s = 0.0
|
||||
for curve in cubic_bezier_interpolation(points):
|
||||
s += sum(linear_distances(curve.approximate(count)))
|
||||
return s, s
|
||||
else:
|
||||
raise ValueError(f"Unknown tangent magnitude calculation method: {method}")
|
||||
|
||||
|
||||
def tangents_3_point_interpolation(
|
||||
fit_points: list[Vec3], method: str = "chord", normalize=True
|
||||
) -> list[Vec3]:
|
||||
"""Returns from 3 points interpolated and optional normalized tangent
|
||||
vectors.
|
||||
"""
|
||||
q = [Q1 - Q0 for Q0, Q1 in zip(fit_points, fit_points[1:])]
|
||||
t = list(create_t_vector(fit_points, method))
|
||||
delta_t = [t1 - t0 for t0, t1 in zip(t, t[1:])]
|
||||
d = [qk / dtk for qk, dtk in zip(q, delta_t)]
|
||||
alpha = [dt0 / (dt0 + dt1) for dt0, dt1 in zip(delta_t, delta_t[1:])]
|
||||
tangents: list[Vec3] = [Vec3()] # placeholder
|
||||
tangents.extend(
|
||||
[(1.0 - alpha[k]) * d[k] + alpha[k] * d[k + 1] for k in range(len(d) - 1)]
|
||||
)
|
||||
tangents[0] = 2.0 * d[0] - tangents[1]
|
||||
tangents.append(2.0 * d[-1] - tangents[-1])
|
||||
if normalize:
|
||||
tangents = [v.normalize() for v in tangents]
|
||||
return tangents
|
||||
|
||||
|
||||
def tangents_5_point_interpolation(
|
||||
fit_points: list[Vec3], normalize=True
|
||||
) -> list[Vec3]:
|
||||
"""Returns from 5 points interpolated and optional normalized tangent
|
||||
vectors.
|
||||
"""
|
||||
n = len(fit_points)
|
||||
q = _delta_q(fit_points)
|
||||
|
||||
alpha = list()
|
||||
for k in range(n):
|
||||
v1 = (q[k - 1].cross(q[k])).magnitude
|
||||
v2 = (q[k + 1].cross(q[k + 2])).magnitude
|
||||
alpha.append(v1 / (v1 + v2))
|
||||
|
||||
tangents = []
|
||||
for k in range(n):
|
||||
vk = (1.0 - alpha[k]) * q[k] + alpha[k] * q[k + 1]
|
||||
tangents.append(vk)
|
||||
if normalize:
|
||||
tangents = [v.normalize() for v in tangents]
|
||||
return tangents
|
||||
|
||||
|
||||
def _delta_q(points: list[Vec3]) -> list[Vec3]:
|
||||
n = len(points)
|
||||
q = [Vec3()] # placeholder
|
||||
q.extend([points[k + 1] - points[k] for k in range(n - 1)])
|
||||
q[0] = 2.0 * q[1] - q[2]
|
||||
q.append(2.0 * q[n - 1] - q[n - 2]) # q[n]
|
||||
q.append(2.0 * q[n] - q[n - 1]) # q[n+1]
|
||||
q.append(2.0 * q[0] - q[1]) # q[-1]
|
||||
return q
|
||||
|
||||
|
||||
def finite_difference_interpolation(
|
||||
fit_points: list[Vec3], normalize=True
|
||||
) -> list[Vec3]:
|
||||
f = 2.0
|
||||
p = fit_points
|
||||
|
||||
t = [(p[1] - p[0]) / f]
|
||||
for k in range(1, len(fit_points) - 1):
|
||||
t.append((p[k] - p[k - 1]) / f + (p[k + 1] - p[k]) / f)
|
||||
t.append((p[-1] - p[-2]) / f)
|
||||
if normalize:
|
||||
t = [v.normalize() for v in t]
|
||||
return t
|
||||
|
||||
|
||||
def cardinal_interpolation(fit_points: list[Vec3], tension: float) -> list[Vec3]:
|
||||
# https://en.wikipedia.org/wiki/Cubic_Hermite_spline
|
||||
def tangent(p0, p1):
|
||||
return (p0 - p1).normalize(1.0 - tension)
|
||||
|
||||
t = [tangent(fit_points[0], fit_points[1])]
|
||||
for k in range(1, len(fit_points) - 1):
|
||||
t.append(tangent(fit_points[k + 1], fit_points[k - 1]))
|
||||
t.append(tangent(fit_points[-1], fit_points[-2]))
|
||||
return t
|
||||
@@ -0,0 +1,426 @@
|
||||
# Copyright (c) 2008, Casey Duncan (casey dot duncan at gmail dot com)
|
||||
# see LICENSE.txt for details
|
||||
"""Noise functions for procedural generation of content
|
||||
|
||||
Contains native code implementations of Perlin improved noise (with
|
||||
fBm capabilities) and Perlin simplex noise. Also contains a fast
|
||||
"fake noise" implementation in GLSL for execution in shaders.
|
||||
|
||||
Copyright (c) 2008, Casey Duncan (casey dot duncan at gmail dot com)
|
||||
"""
|
||||
__version__ = "1.2.1"
|
||||
|
||||
from math import floor, fmod, sqrt
|
||||
from random import randint
|
||||
|
||||
# 3D Gradient vectors
|
||||
# fmt: off
|
||||
_GRAD3 = (
|
||||
(1, 1, 0), (-1, 1, 0), (1, -1, 0), (-1, -1, 0),
|
||||
(1, 0, 1), (-1, 0, 1), (1, 0, -1), (-1, 0, -1),
|
||||
(0, 1, 1), (0, -1, 1), (0, 1, -1), (0, -1, -1),
|
||||
(1, 1, 0), (0, -1, 1), (-1, 1, 0), (0, -1, -1),
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
# 4D Gradient vectors
|
||||
# fmt: off
|
||||
_GRAD4 = (
|
||||
(0, 1, 1, 1), (0, 1, 1, -1), (0, 1, -1, 1), (0, 1, -1, -1),
|
||||
(0, -1, 1, 1), (0, -1, 1, -1), (0, -1, -1, 1), (0, -1, -1, -1),
|
||||
(1, 0, 1, 1), (1, 0, 1, -1), (1, 0, -1, 1), (1, 0, -1, -1),
|
||||
(-1, 0, 1, 1), (-1, 0, 1, -1), (-1, 0, -1, 1), (-1, 0, -1, -1),
|
||||
(1, 1, 0, 1), (1, 1, 0, -1), (1, -1, 0, 1), (1, -1, 0, -1),
|
||||
(-1, 1, 0, 1), (-1, 1, 0, -1), (-1, -1, 0, 1), (-1, -1, 0, -1),
|
||||
(1, 1, 1, 0), (1, 1, -1, 0), (1, -1, 1, 0), (1, -1, -1, 0),
|
||||
(-1, 1, 1, 0), (-1, 1, -1, 0), (-1, -1, 1, 0), (-1, -1, -1, 0)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
# A lookup table to traverse the simplex around a given point in 4D.
|
||||
# Details can be found where this table is used, in the 4D noise method.
|
||||
# fmt: off
|
||||
_SIMPLEX = (
|
||||
(0, 1, 2, 3), (0, 1, 3, 2), (0, 0, 0, 0), (0, 2, 3, 1), (0, 0, 0, 0),
|
||||
(0, 0, 0, 0), (0, 0, 0, 0), (1, 2, 3, 0), (0, 2, 1, 3), (0, 0, 0, 0),
|
||||
(0, 3, 1, 2), (0, 3, 2, 1), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0),
|
||||
(1, 3, 2, 0), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0),
|
||||
(0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0), (1, 2, 0, 3),
|
||||
(0, 0, 0, 0), (1, 3, 0, 2), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0),
|
||||
(2, 3, 0, 1), (2, 3, 1, 0), (1, 0, 2, 3), (1, 0, 3, 2), (0, 0, 0, 0),
|
||||
(0, 0, 0, 0), (0, 0, 0, 0), (2, 0, 3, 1), (0, 0, 0, 0), (2, 1, 3, 0),
|
||||
(0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0),
|
||||
(0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0), (2, 0, 1, 3), (0, 0, 0, 0),
|
||||
(0, 0, 0, 0), (0, 0, 0, 0), (3, 0, 1, 2), (3, 0, 2, 1), (0, 0, 0, 0),
|
||||
(3, 1, 2, 0), (2, 1, 0, 3), (0, 0, 0, 0), (0, 0, 0, 0), (0, 0, 0, 0),
|
||||
(3, 1, 0, 2), (0, 0, 0, 0), (3, 2, 0, 1), (3, 2, 1, 0)
|
||||
)
|
||||
# fmt: on
|
||||
# Simplex skew constants
|
||||
_F2 = 0.5 * (sqrt(3.0) - 1.0)
|
||||
_G2 = (3.0 - sqrt(3.0)) / 6.0
|
||||
_F3 = 1.0 / 3.0
|
||||
_G3 = 1.0 / 6.0
|
||||
|
||||
# fmt: off
|
||||
_permutation = (
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225,
|
||||
140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23, 190, 6, 148, 247,
|
||||
120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57,
|
||||
177, 33, 88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74,
|
||||
165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83, 111, 229, 122,
|
||||
60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54,
|
||||
65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169,
|
||||
200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3,
|
||||
64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85,
|
||||
212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42, 223, 183, 170,
|
||||
213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43,
|
||||
172, 9, 129, 22, 39, 253, 9, 98, 108, 110, 79, 113, 224, 232, 178, 185,
|
||||
112, 104, 218, 246, 97, 228, 251, 34, 242, 193, 238, 210, 144, 12, 191,
|
||||
179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31,
|
||||
181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150,
|
||||
254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195,
|
||||
78, 66, 215, 61, 156, 180
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
|
||||
class BaseNoise:
|
||||
"""Noise abstract base class"""
|
||||
period = len(_permutation)
|
||||
# Double permutation array so we don't need to wrap
|
||||
permutation = _permutation * 2
|
||||
|
||||
def __init__(self, period=None, permutation_table=None):
|
||||
"""Initialize the noise generator. With no arguments, the default
|
||||
period and permutation table are used (256). The default permutation
|
||||
table generates the exact same noise pattern each time.
|
||||
|
||||
An integer period can be specified, to generate a random permutation
|
||||
table with period elements. The period determines the (integer)
|
||||
interval that the noise repeats, which is useful for creating tiled
|
||||
textures. period should be a power-of-two, though this is not
|
||||
enforced. Note that the speed of the noise algorithm is independent of
|
||||
the period size, though larger periods mean a larger table, which
|
||||
consume more memory.
|
||||
|
||||
A permutation table consisting of an iterable sequence of whole
|
||||
numbers can be specified directly. This should have a power-of-two
|
||||
length. Typical permutation tables are a sequence of unique integers in
|
||||
the range [0,period) in random order, though other arrangements could
|
||||
prove useful, they will not be "pure" simplex noise. The largest
|
||||
element in the sequence must be no larger than period-1.
|
||||
|
||||
period and permutation_table may not be specified together.
|
||||
"""
|
||||
if period is not None and permutation_table is not None:
|
||||
raise ValueError(
|
||||
"Can specify either period or permutation_table, not both"
|
||||
)
|
||||
if period is not None:
|
||||
self.randomize(period)
|
||||
elif permutation_table is not None:
|
||||
self.permutation = tuple(permutation_table) * 2
|
||||
self.period = len(permutation_table)
|
||||
|
||||
def randomize(self, period=None):
|
||||
"""Randomize the permutation table used by the noise functions. This
|
||||
makes them generate a different noise pattern for the same inputs.
|
||||
"""
|
||||
if period is not None:
|
||||
self.period = period
|
||||
perm = list(range(self.period))
|
||||
perm_right = self.period - 1
|
||||
for i in list(perm):
|
||||
j = randint(0, perm_right)
|
||||
perm[i], perm[j] = perm[j], perm[i]
|
||||
self.permutation = tuple(perm) * 2
|
||||
|
||||
|
||||
class SimplexNoise(BaseNoise):
|
||||
"""Perlin simplex noise generator
|
||||
|
||||
Adapted from Stefan Gustavson's Java implementation described here:
|
||||
|
||||
http://staffwww.itn.liu.se/~stegu/simplexnoise/simplexnoise.pdf
|
||||
|
||||
To summarize:
|
||||
|
||||
"In 2001, Ken Perlin presented 'simplex noise', a replacement for his classic
|
||||
noise algorithm. Classic 'Perlin noise' won him an academy award and has
|
||||
become an ubiquitous procedural primitive for computer graphics over the
|
||||
years, but in hindsight it has quite a few limitations. Ken Perlin himself
|
||||
designed simplex noise specifically to overcome those limitations, and he
|
||||
spent a lot of good thinking on it. Therefore, it is a better idea than his
|
||||
original algorithm. A few of the more prominent advantages are:
|
||||
|
||||
* Simplex noise has a lower computational complexity and requires fewer
|
||||
multiplications.
|
||||
* Simplex noise scales to higher dimensions (4D, 5D and up) with much less
|
||||
computational cost, the complexity is O(N) for N dimensions instead of
|
||||
the O(2^N) of classic Noise.
|
||||
* Simplex noise has no noticeable directional artifacts. Simplex noise has
|
||||
a well-defined and continuous gradient everywhere that can be computed
|
||||
quite cheaply.
|
||||
* Simplex noise is easy to implement in hardware."
|
||||
"""
|
||||
|
||||
def noise2(self, x, y):
|
||||
"""2D Perlin simplex noise.
|
||||
|
||||
Return a floating point value from -1 to 1 for the given x, y coordinate.
|
||||
The same value is always returned for a given x, y pair unless the
|
||||
permutation table changes (see randomize above).
|
||||
"""
|
||||
# Skew input space to determine which simplex (triangle) we are in
|
||||
s = (x + y) * _F2
|
||||
i = floor(x + s)
|
||||
j = floor(y + s)
|
||||
t = (i + j) * _G2
|
||||
x0 = x - (i - t) # "Unskewed" distances from cell origin
|
||||
y0 = y - (j - t)
|
||||
|
||||
if x0 > y0:
|
||||
i1 = 1
|
||||
j1 = 0 # Lower triangle, XY order: (0,0)->(1,0)->(1,1)
|
||||
else:
|
||||
i1 = 0
|
||||
j1 = 1 # Upper triangle, YX order: (0,0)->(0,1)->(1,1)
|
||||
|
||||
x1 = x0 - i1 + _G2 # Offsets for middle corner in (x,y) unskewed coords
|
||||
y1 = y0 - j1 + _G2
|
||||
x2 = (
|
||||
x0 + _G2 * 2.0 - 1.0
|
||||
) # Offsets for last corner in (x,y) unskewed coords
|
||||
y2 = y0 + _G2 * 2.0 - 1.0
|
||||
|
||||
# Determine hashed gradient indices of the three simplex corners
|
||||
perm = self.permutation
|
||||
ii = int(i) % self.period
|
||||
jj = int(j) % self.period
|
||||
gi0 = perm[ii + perm[jj]] % 12
|
||||
gi1 = perm[ii + i1 + perm[jj + j1]] % 12
|
||||
gi2 = perm[ii + 1 + perm[jj + 1]] % 12
|
||||
|
||||
# Calculate the contribution from the three corners
|
||||
tt = 0.5 - x0 ** 2 - y0 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi0]
|
||||
noise = tt ** 4 * (g[0] * x0 + g[1] * y0)
|
||||
else:
|
||||
noise = 0.0
|
||||
|
||||
tt = 0.5 - x1 ** 2 - y1 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi1]
|
||||
noise += tt ** 4 * (g[0] * x1 + g[1] * y1)
|
||||
|
||||
tt = 0.5 - x2 ** 2 - y2 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi2]
|
||||
noise += tt ** 4 * (g[0] * x2 + g[1] * y2)
|
||||
|
||||
return noise * 70.0 # scale noise to [-1, 1]
|
||||
|
||||
def noise3(self, x, y, z):
|
||||
"""3D Perlin simplex noise.
|
||||
|
||||
Return a floating point value from -1 to 1 for the given x, y, z coordinate.
|
||||
The same value is always returned for a given x, y, z pair unless the
|
||||
permutation table changes (see randomize above).
|
||||
"""
|
||||
# Skew the input space to determine which simplex cell we're in
|
||||
s = (x + y + z) * _F3
|
||||
i = floor(x + s)
|
||||
j = floor(y + s)
|
||||
k = floor(z + s)
|
||||
t = (i + j + k) * _G3
|
||||
x0 = x - (i - t) # "Unskewed" distances from cell origin
|
||||
y0 = y - (j - t)
|
||||
z0 = z - (k - t)
|
||||
|
||||
# For the 3D case, the simplex shape is a slightly irregular tetrahedron.
|
||||
# Determine which simplex we are in.
|
||||
if x0 >= y0:
|
||||
if y0 >= z0:
|
||||
i1 = 1
|
||||
j1 = 0
|
||||
k1 = 0
|
||||
i2 = 1
|
||||
j2 = 1
|
||||
k2 = 0
|
||||
elif x0 >= z0:
|
||||
i1 = 1
|
||||
j1 = 0
|
||||
k1 = 0
|
||||
i2 = 1
|
||||
j2 = 0
|
||||
k2 = 1
|
||||
else:
|
||||
i1 = 0
|
||||
j1 = 0
|
||||
k1 = 1
|
||||
i2 = 1
|
||||
j2 = 0
|
||||
k2 = 1
|
||||
else: # x0 < y0
|
||||
if y0 < z0:
|
||||
i1 = 0
|
||||
j1 = 0
|
||||
k1 = 1
|
||||
i2 = 0
|
||||
j2 = 1
|
||||
k2 = 1
|
||||
elif x0 < z0:
|
||||
i1 = 0
|
||||
j1 = 1
|
||||
k1 = 0
|
||||
i2 = 0
|
||||
j2 = 1
|
||||
k2 = 1
|
||||
else:
|
||||
i1 = 0
|
||||
j1 = 1
|
||||
k1 = 0
|
||||
i2 = 1
|
||||
j2 = 1
|
||||
k2 = 0
|
||||
|
||||
# Offsets for remaining corners
|
||||
x1 = x0 - i1 + _G3
|
||||
y1 = y0 - j1 + _G3
|
||||
z1 = z0 - k1 + _G3
|
||||
x2 = x0 - i2 + 2.0 * _G3
|
||||
y2 = y0 - j2 + 2.0 * _G3
|
||||
z2 = z0 - k2 + 2.0 * _G3
|
||||
x3 = x0 - 1.0 + 3.0 * _G3
|
||||
y3 = y0 - 1.0 + 3.0 * _G3
|
||||
z3 = z0 - 1.0 + 3.0 * _G3
|
||||
|
||||
# Calculate the hashed gradient indices of the four simplex corners
|
||||
perm = self.permutation
|
||||
ii = int(i) % self.period
|
||||
jj = int(j) % self.period
|
||||
kk = int(k) % self.period
|
||||
gi0 = perm[ii + perm[jj + perm[kk]]] % 12
|
||||
gi1 = perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]] % 12
|
||||
gi2 = perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]] % 12
|
||||
gi3 = perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]] % 12
|
||||
|
||||
# Calculate the contribution from the four corners
|
||||
noise = 0.0
|
||||
tt = 0.6 - x0 ** 2 - y0 ** 2 - z0 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi0]
|
||||
noise = tt ** 4 * (g[0] * x0 + g[1] * y0 + g[2] * z0)
|
||||
else:
|
||||
noise = 0.0
|
||||
|
||||
tt = 0.6 - x1 ** 2 - y1 ** 2 - z1 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi1]
|
||||
noise += tt ** 4 * (g[0] * x1 + g[1] * y1 + g[2] * z1)
|
||||
|
||||
tt = 0.6 - x2 ** 2 - y2 ** 2 - z2 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi2]
|
||||
noise += tt ** 4 * (g[0] * x2 + g[1] * y2 + g[2] * z2)
|
||||
|
||||
tt = 0.6 - x3 ** 2 - y3 ** 2 - z3 ** 2
|
||||
if tt > 0:
|
||||
g = _GRAD3[gi3]
|
||||
noise += tt ** 4 * (g[0] * x3 + g[1] * y3 + g[2] * z3)
|
||||
|
||||
return noise * 32.0
|
||||
|
||||
|
||||
def lerp(t, a, b):
|
||||
return a + t * (b - a)
|
||||
|
||||
|
||||
def grad3(hash, x, y, z):
|
||||
g = _GRAD3[hash % 16]
|
||||
return x * g[0] + y * g[1] + z * g[2]
|
||||
|
||||
|
||||
class TileableNoise(BaseNoise):
|
||||
"""Tileable implementation of Perlin "improved" noise. This
|
||||
is based on the reference implementation published here:
|
||||
|
||||
http://mrl.nyu.edu/~perlin/noise/
|
||||
"""
|
||||
|
||||
def noise3(self, x, y, z, repeat, base=0.0):
|
||||
"""Tileable 3D noise.
|
||||
|
||||
repeat specifies the integer interval in each dimension
|
||||
when the noise pattern repeats.
|
||||
|
||||
base allows a different texture to be generated for
|
||||
the same repeat interval.
|
||||
"""
|
||||
i = int(fmod(floor(x), repeat))
|
||||
j = int(fmod(floor(y), repeat))
|
||||
k = int(fmod(floor(z), repeat))
|
||||
ii = (i + 1) % repeat
|
||||
jj = (j + 1) % repeat
|
||||
kk = (k + 1) % repeat
|
||||
if base:
|
||||
i += base
|
||||
j += base
|
||||
k += base
|
||||
ii += base
|
||||
jj += base
|
||||
kk += base
|
||||
|
||||
x -= floor(x)
|
||||
y -= floor(y)
|
||||
z -= floor(z)
|
||||
fx = x ** 3 * (x * (x * 6 - 15) + 10)
|
||||
fy = y ** 3 * (y * (y * 6 - 15) + 10)
|
||||
fz = z ** 3 * (z * (z * 6 - 15) + 10)
|
||||
|
||||
perm = self.permutation
|
||||
A = perm[i]
|
||||
AA = perm[A + j]
|
||||
AB = perm[A + jj]
|
||||
B = perm[ii]
|
||||
BA = perm[B + j]
|
||||
BB = perm[B + jj]
|
||||
|
||||
return lerp(
|
||||
fz,
|
||||
lerp(
|
||||
fy,
|
||||
lerp(
|
||||
fx,
|
||||
grad3(perm[AA + k], x, y, z),
|
||||
grad3(perm[BA + k], x - 1, y, z),
|
||||
),
|
||||
lerp(
|
||||
fx,
|
||||
grad3(perm[AB + k], x, y - 1, z),
|
||||
grad3(perm[BB + k], x - 1, y - 1, z),
|
||||
),
|
||||
),
|
||||
lerp(
|
||||
fy,
|
||||
lerp(
|
||||
fx,
|
||||
grad3(perm[AA + kk], x, y, z - 1),
|
||||
grad3(perm[BA + kk], x - 1, y, z - 1),
|
||||
),
|
||||
lerp(
|
||||
fx,
|
||||
grad3(perm[AB + kk], x, y - 1, z - 1),
|
||||
grad3(perm[BB + kk], x - 1, y - 1, z - 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_simplex = SimplexNoise()
|
||||
snoise2 = _simplex.noise2
|
||||
snoise3 = _simplex.noise3
|
||||
_tileable = TileableNoise()
|
||||
tnoise3 = _tileable.noise3
|
||||
@@ -0,0 +1,474 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Iterable,
|
||||
Tuple,
|
||||
Iterator,
|
||||
Sequence,
|
||||
Dict,
|
||||
)
|
||||
import abc
|
||||
from typing_extensions import Protocol, TypeAlias
|
||||
import numpy as np
|
||||
|
||||
from ezdxf.math import (
|
||||
Vec2,
|
||||
Vec3,
|
||||
UVec,
|
||||
NULLVEC,
|
||||
intersection_line_line_2d,
|
||||
BoundingBox2d,
|
||||
intersection_line_line_3d,
|
||||
BoundingBox,
|
||||
AbstractBoundingBox,
|
||||
)
|
||||
|
||||
|
||||
import bisect
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConstructionPolyline",
|
||||
"ApproxParamT",
|
||||
"intersect_polylines_2d",
|
||||
"intersect_polylines_3d",
|
||||
]
|
||||
|
||||
REL_TOL = 1e-9
|
||||
|
||||
|
||||
class ConstructionPolyline(Sequence):
|
||||
"""Construction tool for 3D polylines.
|
||||
|
||||
A polyline construction tool to measure, interpolate and divide anything
|
||||
that can be approximated or flattened into vertices.
|
||||
This is an immutable data structure which supports the :class:`Sequence`
|
||||
interface.
|
||||
|
||||
Args:
|
||||
vertices: iterable of polyline vertices
|
||||
close: ``True`` to close the polyline (first vertex == last vertex)
|
||||
rel_tol: relative tolerance for floating point comparisons
|
||||
|
||||
Example to measure or divide a SPLINE entity::
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.math import ConstructionPolyline
|
||||
|
||||
doc = ezdxf.readfile("your.dxf")
|
||||
msp = doc.modelspace()
|
||||
spline = msp.query("SPLINE").first
|
||||
if spline is not None:
|
||||
polyline = ConstructionPolyline(spline.flattening(0.01))
|
||||
print(f"Entity {spline} has an approximated length of {polyline.length}")
|
||||
# get dividing points with a distance of 1.0 drawing unit to each other
|
||||
points = list(polyline.divide_by_length(1.0))
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vertices: Iterable[UVec],
|
||||
close: bool = False,
|
||||
rel_tol: float = REL_TOL,
|
||||
):
|
||||
self._rel_tol = float(rel_tol)
|
||||
v3list: list[Vec3] = Vec3.list(vertices)
|
||||
self._vertices: list[Vec3] = v3list
|
||||
if close and len(v3list) > 2:
|
||||
if not v3list[0].isclose(v3list[-1], rel_tol=self._rel_tol):
|
||||
v3list.append(v3list[0])
|
||||
|
||||
self._distances: list[float] = _distances(v3list)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""len(self)"""
|
||||
return len(self._vertices)
|
||||
|
||||
def __iter__(self) -> Iterator[Vec3]:
|
||||
"""iter(self)"""
|
||||
return iter(self._vertices)
|
||||
|
||||
def __getitem__(self, item):
|
||||
"""vertex = self[item]"""
|
||||
if isinstance(item, int):
|
||||
return self._vertices[item]
|
||||
else: # slice
|
||||
return self.__class__(self._vertices[item], rel_tol=self._rel_tol)
|
||||
|
||||
@property
|
||||
def length(self) -> float:
|
||||
"""Returns the overall length of the polyline."""
|
||||
if self._distances:
|
||||
return self._distances[-1]
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Returns ``True`` if the polyline is closed
|
||||
(first vertex == last vertex).
|
||||
"""
|
||||
if len(self._vertices) > 2:
|
||||
return self._vertices[0].isclose(
|
||||
self._vertices[-1], rel_tol=self._rel_tol
|
||||
)
|
||||
return False
|
||||
|
||||
def data(self, index: int) -> tuple[float, float, Vec3]:
|
||||
"""Returns the tuple (distance from start, distance from previous vertex,
|
||||
vertex). All distances measured along the polyline.
|
||||
"""
|
||||
vertices = self._vertices
|
||||
if not vertices:
|
||||
raise ValueError("empty polyline")
|
||||
distances = self._distances
|
||||
|
||||
if index == 0:
|
||||
return 0.0, 0.0, vertices[0]
|
||||
prev_distance = distances[index - 1]
|
||||
current_distance = distances[index]
|
||||
vertex = vertices[index]
|
||||
return current_distance, current_distance - prev_distance, vertex
|
||||
|
||||
def index_at(self, distance: float) -> int:
|
||||
"""Returns the data index of the exact or next data entry for the given
|
||||
`distance`. Returns the index of last entry if `distance` > :attr:`length`.
|
||||
|
||||
"""
|
||||
if distance <= 0.0:
|
||||
return 0
|
||||
if distance >= self.length:
|
||||
return max(0, len(self) - 1)
|
||||
return self._index_at(distance)
|
||||
|
||||
def _index_at(self, distance: float) -> int:
|
||||
# fast method without any checks
|
||||
return bisect.bisect_left(self._distances, distance)
|
||||
|
||||
def vertex_at(self, distance: float) -> Vec3:
|
||||
"""Returns the interpolated vertex at the given `distance` from the
|
||||
start of the polyline.
|
||||
"""
|
||||
if distance < 0.0 or distance > self.length:
|
||||
raise ValueError("distance out of range")
|
||||
if len(self._vertices) < 2:
|
||||
raise ValueError("not enough vertices for interpolation")
|
||||
return self._vertex_at(distance)
|
||||
|
||||
def _vertex_at(self, distance: float) -> Vec3:
|
||||
# fast method without any checks
|
||||
vertices = self._vertices
|
||||
distances = self._distances
|
||||
index1 = self._index_at(distance)
|
||||
if index1 == 0:
|
||||
return vertices[0]
|
||||
index0 = index1 - 1
|
||||
distance1 = distances[index1]
|
||||
distance0 = distances[index0]
|
||||
# skip coincident vertices:
|
||||
while index0 > 0 and distance0 == distance1:
|
||||
index0 -= 1
|
||||
distance0 = distances[index0]
|
||||
if distance0 == distance1:
|
||||
raise ArithmeticError("internal interpolation error")
|
||||
|
||||
factor = (distance - distance0) / (distance1 - distance0)
|
||||
return vertices[index0].lerp(vertices[index1], factor=factor)
|
||||
|
||||
def divide(self, count: int) -> Iterator[Vec3]:
|
||||
"""Returns `count` interpolated vertices along the polyline.
|
||||
Argument `count` has to be greater than 2 and the start- and end
|
||||
vertices are always included.
|
||||
|
||||
"""
|
||||
if count < 2:
|
||||
raise ValueError(f"invalid count: {count}")
|
||||
vertex_at = self._vertex_at
|
||||
for distance in np.linspace(0.0, self.length, count):
|
||||
yield vertex_at(distance)
|
||||
|
||||
def divide_by_length(
|
||||
self, length: float, force_last: bool = False
|
||||
) -> Iterator[Vec3]:
|
||||
"""Returns interpolated vertices along the polyline. Each vertex has a
|
||||
fix distance `length` from its predecessor. Yields the last vertex if
|
||||
argument `force_last` is ``True`` even if the last distance is not equal
|
||||
to `length`.
|
||||
|
||||
"""
|
||||
if length <= 0.0:
|
||||
raise ValueError(f"invalid length: {length}")
|
||||
if len(self._vertices) < 2:
|
||||
raise ValueError("not enough vertices for interpolation")
|
||||
|
||||
total_length: float = self.length
|
||||
vertex_at = self._vertex_at
|
||||
distance: float = 0.0
|
||||
|
||||
vertex: Vec3 = NULLVEC
|
||||
while distance <= total_length:
|
||||
vertex = vertex_at(distance)
|
||||
yield vertex
|
||||
distance += length
|
||||
|
||||
if force_last and not vertex.isclose(self._vertices[-1]):
|
||||
yield self._vertices[-1]
|
||||
|
||||
|
||||
def _distances(vertices: Iterable[Vec3]) -> list[float]:
|
||||
# distance from start vertex of the polyline to the vertex
|
||||
current_station: float = 0.0
|
||||
distances: list[float] = []
|
||||
prev_vertex = Vec3()
|
||||
for vertex in vertices:
|
||||
if distances:
|
||||
distant_vec = vertex - prev_vertex
|
||||
current_station += distant_vec.magnitude
|
||||
distances.append(current_station)
|
||||
else:
|
||||
distances.append(current_station)
|
||||
prev_vertex = vertex
|
||||
return distances
|
||||
|
||||
|
||||
class SupportsPointMethod(Protocol):
|
||||
def point(self, t: float) -> UVec:
|
||||
...
|
||||
|
||||
|
||||
class ApproxParamT:
|
||||
"""Approximation tool for parametrized curves.
|
||||
|
||||
- approximate parameter `t` for a given distance from the start of the curve
|
||||
- approximate the distance for a given parameter `t` from the start of the curve
|
||||
|
||||
These approximations can be applied to all parametrized curves which provide
|
||||
a :meth:`point` method, like :class:`Bezier4P`, :class:`Bezier3P` and
|
||||
:class:`BSpline`.
|
||||
|
||||
The approximation is based on equally spaced parameters from 0 to `max_t`
|
||||
for a given segment count.
|
||||
The :meth:`flattening` method can not be used for the curve approximation,
|
||||
because the required parameter `t` is not logged by the flattening process.
|
||||
|
||||
Args:
|
||||
curve: curve object, requires a method :meth:`point`
|
||||
max_t: the max. parameter value
|
||||
segments: count of approximation segments
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
curve: SupportsPointMethod,
|
||||
*,
|
||||
max_t: float = 1.0,
|
||||
segments: int = 100,
|
||||
):
|
||||
assert hasattr(curve, "point")
|
||||
assert segments > 0
|
||||
self._polyline = ConstructionPolyline(
|
||||
curve.point(t) for t in np.linspace(0.0, max_t, segments + 1)
|
||||
)
|
||||
self._max_t = max_t
|
||||
self._step = max_t / segments
|
||||
|
||||
@property
|
||||
def max_t(self) -> float:
|
||||
return self._max_t
|
||||
|
||||
@property
|
||||
def polyline(self) -> ConstructionPolyline:
|
||||
return self._polyline
|
||||
|
||||
def param_t(self, distance: float):
|
||||
"""Approximate parameter t for the given `distance` from the start of
|
||||
the curve.
|
||||
"""
|
||||
poly = self._polyline
|
||||
if distance >= poly.length:
|
||||
return self._max_t
|
||||
|
||||
t_step = self._step
|
||||
i = poly.index_at(distance)
|
||||
station, d0, _ = poly.data(i)
|
||||
t = t_step * i # t for station
|
||||
if d0 > 1e-12:
|
||||
t -= t_step * (station - distance) / d0
|
||||
return min(self._max_t, t)
|
||||
|
||||
def distance(self, t: float) -> float:
|
||||
"""Approximate the distance from the start of the curve to the point
|
||||
`t` on the curve.
|
||||
"""
|
||||
if t <= 0.0:
|
||||
return 0.0
|
||||
poly = self._polyline
|
||||
if t >= self._max_t:
|
||||
return poly.length
|
||||
|
||||
step = self._step
|
||||
index = int(t / step) + 1
|
||||
station, d0, _ = poly.data(index)
|
||||
return station - d0 * (step * index - t) / step
|
||||
|
||||
|
||||
def intersect_polylines_2d(
|
||||
p1: Sequence[Vec2], p2: Sequence[Vec2], abs_tol=1e-10
|
||||
) -> list[Vec2]:
|
||||
"""Returns the intersection points for two polylines as list of :class:`Vec2`
|
||||
objects, the list is empty if no intersection points exist.
|
||||
Does not return self intersection points of `p1` or `p2`.
|
||||
Duplicate intersection points are removed from the result list, but the list
|
||||
does not have a particular order! You can sort the result list by
|
||||
:code:`result.sort()` to introduce an order.
|
||||
|
||||
Args:
|
||||
p1: first polyline as sequence of :class:`Vec2` objects
|
||||
p2: second polyline as sequence of :class:`Vec2` objects
|
||||
abs_tol: absolute tolerance for comparisons
|
||||
|
||||
"""
|
||||
intersect = _PolylineIntersection2d(p1, p2, abs_tol)
|
||||
intersect.execute()
|
||||
return intersect.intersections
|
||||
|
||||
|
||||
def intersect_polylines_3d(
|
||||
p1: Sequence[Vec3], p2: Sequence[Vec3], abs_tol=1e-10
|
||||
) -> list[Vec3]:
|
||||
"""Returns the intersection points for two polylines as list of :class:`Vec3`
|
||||
objects, the list is empty if no intersection points exist.
|
||||
Does not return self intersection points of `p1` or `p2`.
|
||||
Duplicate intersection points are removed from the result list, but the list
|
||||
does not have a particular order! You can sort the result list by
|
||||
:code:`result.sort()` to introduce an order.
|
||||
|
||||
Args:
|
||||
p1: first polyline as sequence of :class:`Vec3` objects
|
||||
p2: second polyline as sequence of :class:`Vec3` objects
|
||||
abs_tol: absolute tolerance for comparisons
|
||||
|
||||
"""
|
||||
intersect = _PolylineIntersection3d(p1, p2, abs_tol)
|
||||
intersect.execute()
|
||||
return intersect.intersections
|
||||
|
||||
|
||||
def divide(a: int, b: int) -> tuple[int, int, int, int]:
|
||||
m = (a + b) // 2
|
||||
return a, m, m, b
|
||||
|
||||
|
||||
TCache: TypeAlias = Dict[Tuple[int, int, int], AbstractBoundingBox]
|
||||
|
||||
|
||||
class _PolylineIntersection:
|
||||
p1: Sequence
|
||||
p2: Sequence
|
||||
|
||||
def __init__(self) -> None:
|
||||
# At each recursion level the bounding box for each half of the
|
||||
# polyline will be created two times, using a cache is an advantage:
|
||||
self.bbox_cache: TCache = {}
|
||||
|
||||
@abc.abstractmethod
|
||||
def bbox(self, points: Sequence) -> AbstractBoundingBox:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def line_intersection(self, s1: int, e1: int, s2: int, e2: int) -> None:
|
||||
...
|
||||
|
||||
def execute(self) -> None:
|
||||
l1: int = len(self.p1)
|
||||
l2: int = len(self.p2)
|
||||
if l1 < 2 or l2 < 2: # polylines with only one vertex
|
||||
return
|
||||
self.intersect(0, l1 - 1, 0, l2 - 1)
|
||||
|
||||
def overlap(self, s1: int, e1: int, s2: int, e2: int) -> bool:
|
||||
e1 += 1
|
||||
e2 += 1
|
||||
# If one part of the polylines has less than 2 vertices no intersection
|
||||
# calculation is required:
|
||||
if e1 - s1 < 2 or e2 - s2 < 2:
|
||||
return False
|
||||
|
||||
cache = self.bbox_cache
|
||||
key1 = (1, s1, e1)
|
||||
bbox1 = cache.get(key1)
|
||||
if bbox1 is None:
|
||||
bbox1 = self.bbox(self.p1[s1:e1])
|
||||
cache[key1] = bbox1
|
||||
|
||||
key2 = (2, s2, e2)
|
||||
bbox2 = cache.get(key2)
|
||||
if bbox2 is None:
|
||||
bbox2 = self.bbox(self.p2[s2:e2])
|
||||
cache[key2] = bbox2
|
||||
return bbox1.has_overlap(bbox2)
|
||||
|
||||
def intersect(self, s1: int, e1: int, s2: int, e2: int) -> None:
|
||||
assert e1 > s1 and e2 > s2
|
||||
if e1 - s1 == 1 and e2 - s2 == 1:
|
||||
self.line_intersection(s1, e1, s2, e2)
|
||||
return
|
||||
s1_a, e1_b, s1_c, e1_d = divide(s1, e1)
|
||||
s2_a, e2_b, s2_c, e2_d = divide(s2, e2)
|
||||
|
||||
if self.overlap(s1_a, e1_b, s2_a, e2_b):
|
||||
self.intersect(s1_a, e1_b, s2_a, e2_b)
|
||||
if self.overlap(s1_a, e1_b, s2_c, e2_d):
|
||||
self.intersect(s1_a, e1_b, s2_c, e2_d)
|
||||
if self.overlap(s1_c, e1_d, s2_a, e2_b):
|
||||
self.intersect(s1_c, e1_d, s2_a, e2_b)
|
||||
if self.overlap(s1_c, e1_d, s2_c, e2_d):
|
||||
self.intersect(s1_c, e1_d, s2_c, e2_d)
|
||||
|
||||
|
||||
class _PolylineIntersection2d(_PolylineIntersection):
|
||||
def __init__(self, p1: Sequence[Vec2], p2: Sequence[Vec2], abs_tol=1e-10):
|
||||
super().__init__()
|
||||
self.p1 = p1
|
||||
self.p2 = p2
|
||||
self.intersections: list[Vec2] = []
|
||||
self.abs_tol = abs_tol
|
||||
|
||||
def bbox(self, points: Sequence) -> AbstractBoundingBox:
|
||||
return BoundingBox2d(points)
|
||||
|
||||
def line_intersection(self, s1: int, e1: int, s2: int, e2: int) -> None:
|
||||
line1 = self.p1[s1], self.p1[e1]
|
||||
line2 = self.p2[s2], self.p2[e2]
|
||||
p = intersection_line_line_2d(
|
||||
line1, line2, virtual=False, abs_tol=self.abs_tol
|
||||
)
|
||||
if p is not None and not any(
|
||||
p.isclose(ip, abs_tol=self.abs_tol) for ip in self.intersections
|
||||
):
|
||||
self.intersections.append(p)
|
||||
|
||||
|
||||
class _PolylineIntersection3d(_PolylineIntersection):
|
||||
def __init__(self, p1: Sequence[Vec3], p2: Sequence[Vec3], abs_tol=1e-10):
|
||||
super().__init__()
|
||||
self.p1 = p1
|
||||
self.p2 = p2
|
||||
self.intersections: list[Vec3] = []
|
||||
self.abs_tol = abs_tol
|
||||
|
||||
def bbox(self, points: Sequence) -> AbstractBoundingBox:
|
||||
return BoundingBox(points)
|
||||
|
||||
def line_intersection(self, s1: int, e1: int, s2: int, e2: int) -> None:
|
||||
line1 = self.p1[s1], self.p1[e1]
|
||||
line2 = self.p2[s2], self.p2[e2]
|
||||
p = intersection_line_line_3d(
|
||||
line1, line2, virtual=False, abs_tol=self.abs_tol
|
||||
)
|
||||
if p is not None and not any(
|
||||
p.isclose(ip, abs_tol=self.abs_tol) for ip in self.intersections
|
||||
):
|
||||
self.intersections.append(p)
|
||||
@@ -0,0 +1,328 @@
|
||||
# Copyright (c) 2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
# Immutable spatial search tree based on the SsTree implementation of the book
|
||||
# "Advanced Algorithms and Data Structures"
|
||||
# - SsTree JavaScript source code:
|
||||
# (c) 2019, Marcello La Rocca, released under the GNU Affero General Public License v3.0
|
||||
# https://github.com/mlarocca/AlgorithmsAndDataStructuresInAction/tree/master/JavaScript/src/ss_tree
|
||||
# - Research paper of Antonin Guttman:
|
||||
# http://www-db.deis.unibo.it/courses/SI-LS/papers/Gut84.pdf
|
||||
from __future__ import annotations
|
||||
from operator import itemgetter
|
||||
import statistics
|
||||
from typing import Iterator, Callable, Sequence, Iterable, TypeVar, Generic
|
||||
import abc
|
||||
import math
|
||||
|
||||
from ezdxf.math import BoundingBox, Vec2, Vec3, spherical_envelope
|
||||
|
||||
__all__ = ["RTree"]
|
||||
|
||||
INF = float("inf")
|
||||
|
||||
T = TypeVar("T", Vec2, Vec3)
|
||||
|
||||
|
||||
class Node(abc.ABC, Generic[T]):
|
||||
__slots__ = ("bbox",)
|
||||
|
||||
def __init__(self, bbox: BoundingBox):
|
||||
self.bbox: BoundingBox = bbox
|
||||
|
||||
@abc.abstractmethod
|
||||
def __len__(self) -> int: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def __iter__(self) -> Iterator[T]: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def contains(self, point: T) -> bool: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def _nearest_neighbor(
|
||||
self, target: T, nn: T = None, nn_dist: float = INF
|
||||
) -> tuple[T, float]: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def points_in_sphere(self, center: T, radius: float) -> Iterator[T]: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def points_in_bbox(self, bbox: BoundingBox) -> Iterator[T]: ...
|
||||
|
||||
def nearest_neighbor(self, target: T) -> tuple[T, float]:
|
||||
return self._nearest_neighbor(target)
|
||||
|
||||
|
||||
class LeafNode(Node[T]):
|
||||
__slots__ = ("points", "bbox")
|
||||
|
||||
def __init__(self, points: list[T]):
|
||||
self.points = tuple(points)
|
||||
super().__init__(BoundingBox(self.points))
|
||||
|
||||
def __len__(self):
|
||||
return len(self.points)
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
return iter(self.points)
|
||||
|
||||
def contains(self, point: T) -> bool:
|
||||
return any(point.isclose(p) for p in self.points)
|
||||
|
||||
def _nearest_neighbor(
|
||||
self, target: T, nn: T = None, nn_dist: float = INF
|
||||
) -> tuple[T, float]:
|
||||
distance, point = min((target.distance(p), p) for p in self.points)
|
||||
if distance < nn_dist:
|
||||
nn, nn_dist = point, distance
|
||||
return nn, nn_dist
|
||||
|
||||
def points_in_sphere(self, center: T, radius: float) -> Iterator[T]:
|
||||
return (p for p in self.points if center.distance(p) <= radius)
|
||||
|
||||
def points_in_bbox(self, bbox: BoundingBox) -> Iterator[T]:
|
||||
return (p for p in self.points if bbox.inside(p))
|
||||
|
||||
|
||||
class InnerNode(Node[T]):
|
||||
__slots__ = ("children", "bbox")
|
||||
|
||||
def __init__(self, children: Sequence[Node[T]]):
|
||||
super().__init__(BoundingBox())
|
||||
self.children = tuple(children)
|
||||
for child in self.children:
|
||||
# build union of all child bounding boxes
|
||||
self.bbox.extend([child.bbox.extmin, child.bbox.extmax])
|
||||
|
||||
def __len__(self) -> int:
|
||||
return sum(len(c) for c in self.children)
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
for child in self.children:
|
||||
yield from iter(child)
|
||||
|
||||
def contains(self, point: T) -> bool:
|
||||
for child in self.children:
|
||||
if child.bbox.inside(point) and child.contains(point):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _nearest_neighbor(
|
||||
self, target: T, nn: T = None, nn_dist: float = INF
|
||||
) -> tuple[T, float]:
|
||||
closest_child = find_closest_child(self.children, target)
|
||||
nn, nn_dist = closest_child._nearest_neighbor(target, nn, nn_dist)
|
||||
for child in self.children:
|
||||
if child is closest_child:
|
||||
continue
|
||||
# is target inside the child bounding box + nn_dist in all directions
|
||||
if grow_box(child.bbox, nn_dist).inside(target):
|
||||
point, distance = child._nearest_neighbor(target, nn, nn_dist)
|
||||
if distance < nn_dist:
|
||||
nn = point
|
||||
nn_dist = distance
|
||||
return nn, nn_dist
|
||||
|
||||
def points_in_sphere(self, center: T, radius: float) -> Iterator[T]:
|
||||
for child in self.children:
|
||||
if is_sphere_intersecting_bbox(
|
||||
Vec3(center), radius, child.bbox.center, child.bbox.size
|
||||
):
|
||||
yield from child.points_in_sphere(center, radius)
|
||||
|
||||
def points_in_bbox(self, bbox: BoundingBox) -> Iterator[T]:
|
||||
for child in self.children:
|
||||
if bbox.has_overlap(child.bbox):
|
||||
yield from child.points_in_bbox(bbox)
|
||||
|
||||
|
||||
class RTree(Generic[T]):
|
||||
"""Immutable spatial search tree loosely based on `R-trees`_.
|
||||
|
||||
The search tree is buildup once at initialization and immutable afterwards,
|
||||
because rebuilding the tree after inserting or deleting nodes is very costly
|
||||
and makes the implementation very complex.
|
||||
|
||||
Without the ability to alter the content the restrictions which forces the tree
|
||||
balance at growing and shrinking of the original `R-trees`_, are ignored, like the
|
||||
fixed minimum and maximum node size.
|
||||
|
||||
This class uses internally only 3D bounding boxes, but also supports
|
||||
:class:`Vec2` as well as :class:`Vec3` objects as input data, but point
|
||||
types should not be mixed in a search tree.
|
||||
|
||||
The point objects keep their type and identity and the returned points of
|
||||
queries can be compared by the ``is`` operator for identity to the input
|
||||
points.
|
||||
|
||||
The implementation requires a maximum node size of at least 2 and
|
||||
does not support empty trees!
|
||||
|
||||
Raises:
|
||||
ValueError: max. node size too small or no data given
|
||||
|
||||
.. _R-trees: https://en.wikipedia.org/wiki/R-tree
|
||||
|
||||
"""
|
||||
|
||||
__slots__ = ("_root",)
|
||||
|
||||
def __init__(self, points: Iterable[T], max_node_size: int = 5):
|
||||
if max_node_size < 2:
|
||||
raise ValueError("max node size must be > 1")
|
||||
_points = list(points)
|
||||
if len(_points) == 0:
|
||||
raise ValueError("no points given")
|
||||
self._root = make_node(_points, max_node_size, box_split)
|
||||
|
||||
def __len__(self):
|
||||
"""Returns the count of points in the search tree."""
|
||||
return len(self._root)
|
||||
|
||||
def __iter__(self) -> Iterator[T]:
|
||||
"""Yields all points in the search tree."""
|
||||
yield from iter(self._root)
|
||||
|
||||
def contains(self, point: T) -> bool:
|
||||
"""Returns ``True`` if `point` exists, the comparison is done by the
|
||||
:meth:`isclose` method and not by the identity operator ``is``.
|
||||
"""
|
||||
return self._root.contains(point)
|
||||
|
||||
def nearest_neighbor(self, target: T) -> tuple[T, float]:
|
||||
"""Returns the closest point to the `target` point and the distance
|
||||
between these points.
|
||||
"""
|
||||
return self._root.nearest_neighbor(target)
|
||||
|
||||
def points_in_sphere(self, center: T, radius: float) -> Iterator[T]:
|
||||
"""Returns all points in the range of the given sphere including the
|
||||
points at the boundary.
|
||||
"""
|
||||
return self._root.points_in_sphere(center, radius)
|
||||
|
||||
def points_in_bbox(self, bbox: BoundingBox) -> Iterator[T]:
|
||||
"""Returns all points in the range of the given bounding box including
|
||||
the points at the boundary.
|
||||
"""
|
||||
return self._root.points_in_bbox(bbox)
|
||||
|
||||
def avg_leaf_size(self, spread: float = 1.0) -> float:
|
||||
"""Returns the average size of the leaf bounding boxes.
|
||||
The size of a leaf bounding box is the maximum size in all dimensions.
|
||||
Excludes outliers of sizes beyond mean + standard deviation * spread.
|
||||
Returns 0.0 if less than two points in tree.
|
||||
"""
|
||||
sizes: list[float] = [
|
||||
max(leaf.bbox.size.xyz) for leaf in collect_leafs(self._root)
|
||||
]
|
||||
return average_exclusive_outliers(sizes, spread)
|
||||
|
||||
def avg_spherical_envelope_radius(self, spread: float = 1.0) -> float:
|
||||
"""Returns the average radius of spherical envelopes of the leaf nodes.
|
||||
Excludes outliers with radius beyond mean + standard deviation * spread.
|
||||
Returns 0.0 if less than two points in tree.
|
||||
"""
|
||||
radii: list[float] = [
|
||||
spherical_envelope(leaf.points)[1] for leaf in collect_leafs(self._root)
|
||||
]
|
||||
return average_exclusive_outliers(radii, spread)
|
||||
|
||||
def avg_nn_distance(self, spread: float = 1.0) -> float:
|
||||
"""Returns the average of the nearest neighbor distances inside (!)
|
||||
leaf nodes. Excludes outliers with a distance beyond the overall
|
||||
mean + standard deviation * spread. Returns 0.0 if less than two points
|
||||
in tree.
|
||||
|
||||
.. warning::
|
||||
|
||||
This is a brute force check with O(n!) for each leaf node, where n
|
||||
is the point count of the leaf node.
|
||||
|
||||
"""
|
||||
distances: list[float] = []
|
||||
for leaf in collect_leafs(self._root):
|
||||
distances.extend(nearest_neighbor_distances(leaf.points))
|
||||
return average_exclusive_outliers(distances, spread)
|
||||
|
||||
|
||||
def make_node(
|
||||
points: list[T],
|
||||
max_size: int,
|
||||
split_strategy: Callable[[list[T], int], Sequence[Node]],
|
||||
) -> Node[T]:
|
||||
if len(points) > max_size:
|
||||
return InnerNode(split_strategy(points, max_size))
|
||||
else:
|
||||
return LeafNode(points)
|
||||
|
||||
|
||||
def box_split(points: list[T], max_size: int) -> Sequence[Node[T]]:
|
||||
n = len(points)
|
||||
size: tuple[float, float, float] = BoundingBox(points).size.xyz
|
||||
dim = size.index(max(size))
|
||||
points.sort(key=itemgetter(dim))
|
||||
k = math.ceil(n / max_size)
|
||||
return tuple(
|
||||
make_node(points[i : i + k], max_size, box_split) for i in range(0, n, k)
|
||||
)
|
||||
|
||||
|
||||
def is_sphere_intersecting_bbox(
|
||||
centroid: Vec3, radius: float, center: Vec3, size: Vec3
|
||||
) -> bool:
|
||||
distance = centroid - center
|
||||
intersection_distance = size * 0.5 + Vec3(radius, radius, radius)
|
||||
# non-intersection is more often likely:
|
||||
if abs(distance.x) > intersection_distance.x:
|
||||
return False
|
||||
if abs(distance.y) > intersection_distance.y:
|
||||
return False
|
||||
if abs(distance.z) > intersection_distance.z:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def find_closest_child(children: Sequence[Node[T]], point: T) -> Node[T]:
|
||||
def distance(child: Node) -> float:
|
||||
return point.distance(child.bbox.center)
|
||||
|
||||
assert len(children) > 0
|
||||
return min(children, key=distance)
|
||||
|
||||
|
||||
def grow_box(box: BoundingBox, dist: float) -> BoundingBox:
|
||||
bbox = box.copy()
|
||||
bbox.grow(dist)
|
||||
return bbox
|
||||
|
||||
|
||||
def average_exclusive_outliers(values: list[float], spread: float) -> float:
|
||||
if len(values) < 2:
|
||||
return 0.0
|
||||
stdev = statistics.stdev(values)
|
||||
mean = sum(values) / len(values)
|
||||
max_value = mean + stdev * spread
|
||||
values = [value for value in values if value <= max_value]
|
||||
if len(values):
|
||||
return sum(values) / len(values)
|
||||
return 0.0
|
||||
|
||||
|
||||
def collect_leafs(node: Node[T]) -> Iterable[LeafNode[T]]:
|
||||
"""Yields all leaf nodes below the given node."""
|
||||
if isinstance(node, LeafNode):
|
||||
yield node
|
||||
elif isinstance(node, InnerNode):
|
||||
for child in node.children:
|
||||
yield from collect_leafs(child)
|
||||
|
||||
|
||||
def nearest_neighbor_distances(points: Sequence[T]) -> list[float]:
|
||||
"""Brute force calculation of nearest neighbor distances with a
|
||||
complexity of O(n!).
|
||||
"""
|
||||
return [
|
||||
min(point.distance(p) for p in points[index + 1 :])
|
||||
for index, point in enumerate(points[:-1])
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
# Copyright (c) 2019-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Optional, Iterator, TYPE_CHECKING, overload
|
||||
import math
|
||||
from ezdxf.math import Vec2, UVec, Matrix44
|
||||
|
||||
|
||||
__all__ = ["Shape2d"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.math import BoundingBox2d
|
||||
|
||||
|
||||
class Shape2d:
|
||||
"""Construction tools for 2D shapes.
|
||||
|
||||
A 2D geometry object as list of :class:`Vec2` objects, vertices can be
|
||||
moved, rotated and scaled.
|
||||
|
||||
Args:
|
||||
vertices: iterable of :class:`Vec2` compatible objects.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, vertices: Optional[Iterable[UVec]] = None):
|
||||
from ezdxf.npshapes import NumpyPoints2d
|
||||
|
||||
_vec2: Iterator[Vec2] | None = None
|
||||
if vertices is not None:
|
||||
_vec2 = Vec2.generate(vertices)
|
||||
self.np_vertices = NumpyPoints2d(_vec2)
|
||||
|
||||
@property
|
||||
def vertices(self) -> list[Vec2]:
|
||||
return self.np_vertices.vertices()
|
||||
|
||||
@property
|
||||
def bounding_box(self) -> BoundingBox2d:
|
||||
"""Returns the bounding box of the shape."""
|
||||
return self.np_vertices.bbox()
|
||||
|
||||
def copy(self) -> Shape2d:
|
||||
clone = self.__class__()
|
||||
clone.np_vertices = self.np_vertices.clone()
|
||||
return clone
|
||||
|
||||
__copy__ = copy
|
||||
|
||||
def translate(self, vector: UVec) -> None:
|
||||
"""Translate shape about `vector`."""
|
||||
offset = Vec2(vector)
|
||||
self.np_vertices.transform_inplace(Matrix44.translate(offset.x, offset.y, 0))
|
||||
|
||||
def scale(self, sx: float = 1.0, sy: float = 1.0) -> None:
|
||||
"""Scale shape about `sx` in x-axis and `sy` in y-axis."""
|
||||
self.np_vertices.transform_inplace(Matrix44.scale(sx, sy, 1))
|
||||
|
||||
def scale_uniform(self, scale: float) -> None:
|
||||
"""Scale shape uniform about `scale` in x- and y-axis."""
|
||||
self.np_vertices.transform_inplace(Matrix44.scale(scale, scale, 1))
|
||||
|
||||
def rotate(self, angle: float, center: Optional[UVec] = None) -> None:
|
||||
"""Rotate shape around rotation `center` about `angle` in degrees."""
|
||||
self.rotate_rad(math.radians(angle), center)
|
||||
|
||||
def rotate_rad(self, angle: float, center: Optional[UVec] = None) -> None:
|
||||
"""Rotate shape around rotation `center` about `angle` in radians."""
|
||||
m = Matrix44.z_rotate(angle)
|
||||
if center is not None:
|
||||
p = Vec2(center)
|
||||
m = (
|
||||
Matrix44.translate(-p.x, -p.y, 0)
|
||||
@ m
|
||||
@ Matrix44.translate(p.x, p.y, 0)
|
||||
)
|
||||
self.np_vertices.transform_inplace(m)
|
||||
|
||||
def offset(self, offset: float, closed: bool = False) -> Shape2d:
|
||||
"""Returns a new offset shape, for more information see also
|
||||
:func:`ezdxf.math.offset_vertices_2d` function.
|
||||
|
||||
Args:
|
||||
offset: line offset perpendicular to direction of shape segments
|
||||
defined by vertices order, offset > ``0`` is 'left' of line
|
||||
segment, offset < ``0`` is 'right' of line segment
|
||||
closed: ``True`` to handle as closed shape
|
||||
|
||||
"""
|
||||
from ezdxf.math.offset2d import offset_vertices_2d
|
||||
|
||||
return self.__class__(
|
||||
offset_vertices_2d(
|
||||
self.np_vertices.vertices(), offset=offset, closed=closed
|
||||
)
|
||||
)
|
||||
|
||||
def convex_hull(self) -> Shape2d:
|
||||
"""Returns convex hull as new shape."""
|
||||
from ezdxf.math.construct2d import convex_hull_2d
|
||||
|
||||
return self.__class__(convex_hull_2d(self.np_vertices.vertices()))
|
||||
|
||||
# Sequence interface
|
||||
def __len__(self) -> int:
|
||||
"""Returns `count` of vertices."""
|
||||
return len(self.np_vertices)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, item: int) -> Vec2: ...
|
||||
@overload
|
||||
def __getitem__(self, item: slice) -> list[Vec2]: ...
|
||||
def __getitem__(self, item: int | slice) -> Vec2|list[Vec2]:
|
||||
"""Get vertex by index `item`, supports ``list`` like slicing."""
|
||||
np_vertices = self.np_vertices.np_vertices()
|
||||
if isinstance(item, int):
|
||||
return Vec2(np_vertices[item])
|
||||
else:
|
||||
return Vec2.list(np_vertices[item])
|
||||
|
||||
def append(self, vertex: UVec) -> None:
|
||||
"""Append single `vertex`.
|
||||
|
||||
Args:
|
||||
vertex: vertex as :class:`Vec2` compatible object
|
||||
|
||||
"""
|
||||
self.extend((vertex,))
|
||||
|
||||
def extend(self, vertices: Iterable[UVec]) -> None:
|
||||
"""Append multiple `vertices`.
|
||||
|
||||
Args:
|
||||
vertices: iterable of vertices as :class:`Vec2` compatible objects
|
||||
|
||||
"""
|
||||
from ezdxf.npshapes import NumpyPoints2d
|
||||
|
||||
new_vertices = self.np_vertices.vertices() + Vec2.list(vertices)
|
||||
self.np_vertices = NumpyPoints2d(new_vertices)
|
||||
@@ -0,0 +1,327 @@
|
||||
# Copyright (c) 2020-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
import math
|
||||
from ezdxf.math import (
|
||||
Vec3,
|
||||
Vec2,
|
||||
UVec,
|
||||
X_AXIS,
|
||||
Y_AXIS,
|
||||
Z_AXIS,
|
||||
Matrix44,
|
||||
sign,
|
||||
OCS,
|
||||
arc_angle_span_rad,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.entities import DXFGraphic
|
||||
|
||||
__all__ = [
|
||||
"TransformError",
|
||||
"NonUniformScalingError",
|
||||
"InsertTransformationError",
|
||||
"transform_extrusion",
|
||||
"transform_thickness_and_extrusion_without_ocs",
|
||||
"OCSTransform",
|
||||
"WCSTransform",
|
||||
"InsertCoordinateSystem",
|
||||
]
|
||||
|
||||
_PLACEHOLDER_OCS = OCS()
|
||||
DEG = 180.0 / math.pi # radians to degrees
|
||||
RADIANS = math.pi / 180.0 # degrees to radians
|
||||
|
||||
|
||||
class TransformError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NonUniformScalingError(TransformError):
|
||||
pass
|
||||
|
||||
|
||||
class InsertTransformationError(TransformError):
|
||||
pass
|
||||
|
||||
|
||||
def transform_thickness_and_extrusion_without_ocs(
|
||||
entity: DXFGraphic, m: Matrix44
|
||||
) -> None:
|
||||
if entity.dxf.hasattr("thickness"):
|
||||
thickness = entity.dxf.thickness
|
||||
reflection = sign(thickness)
|
||||
thickness = m.transform_direction(entity.dxf.extrusion * thickness)
|
||||
entity.dxf.thickness = thickness.magnitude * reflection
|
||||
entity.dxf.extrusion = thickness.normalize()
|
||||
elif entity.dxf.hasattr("extrusion"): # without thickness?
|
||||
extrusion = m.transform_direction(entity.dxf.extrusion)
|
||||
entity.dxf.extrusion = extrusion.normalize()
|
||||
|
||||
|
||||
def transform_extrusion(extrusion: UVec, m: Matrix44) -> tuple[Vec3, bool]:
|
||||
"""Transforms the old `extrusion` vector into a new extrusion vector.
|
||||
Returns the new extrusion vector and a boolean value: ``True`` if the new
|
||||
OCS established by the new extrusion vector has a uniform scaled xy-plane,
|
||||
else ``False``.
|
||||
|
||||
The new extrusion vector is perpendicular to plane defined by the
|
||||
transformed x- and y-axis.
|
||||
|
||||
Args:
|
||||
extrusion: extrusion vector of the old OCS
|
||||
m: transformation matrix
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
ocs = OCS(extrusion)
|
||||
ocs_x_axis_in_wcs = ocs.to_wcs(X_AXIS)
|
||||
ocs_y_axis_in_wcs = ocs.to_wcs(Y_AXIS)
|
||||
x_axis, y_axis = m.transform_directions((ocs_x_axis_in_wcs, ocs_y_axis_in_wcs))
|
||||
|
||||
# Check for uniform scaled xy-plane:
|
||||
is_uniform = math.isclose(
|
||||
x_axis.magnitude_square, y_axis.magnitude_square, abs_tol=1e-9
|
||||
)
|
||||
new_extrusion = x_axis.cross(y_axis).normalize()
|
||||
return new_extrusion, is_uniform
|
||||
|
||||
|
||||
class OCSTransform:
|
||||
def __init__(self, extrusion: Vec3 | None = None, m: Matrix44 | None = None):
|
||||
if m is None:
|
||||
self.m = Matrix44()
|
||||
else:
|
||||
self.m = m
|
||||
self.scale_uniform: bool = True
|
||||
if extrusion is None: # fill in dummy values
|
||||
self._reset_ocs(_PLACEHOLDER_OCS, _PLACEHOLDER_OCS, True)
|
||||
else:
|
||||
new_extrusion, scale_uniform = transform_extrusion(extrusion, m)
|
||||
self._reset_ocs(OCS(extrusion), OCS(new_extrusion), scale_uniform)
|
||||
|
||||
def _reset_ocs(self, old_ocs: OCS, new_ocs: OCS, scale_uniform: bool) -> None:
|
||||
self.old_ocs = old_ocs
|
||||
self.new_ocs = new_ocs
|
||||
self.scale_uniform = scale_uniform
|
||||
|
||||
@property
|
||||
def old_extrusion(self) -> Vec3:
|
||||
return self.old_ocs.uz
|
||||
|
||||
@property
|
||||
def new_extrusion(self) -> Vec3:
|
||||
return self.new_ocs.uz
|
||||
|
||||
@classmethod
|
||||
def from_ocs(
|
||||
cls, old: OCS, new: OCS, m: Matrix44, scale_uniform=True
|
||||
) -> OCSTransform:
|
||||
ocs = cls(m=m)
|
||||
ocs._reset_ocs(old, new, scale_uniform)
|
||||
return ocs
|
||||
|
||||
def transform_length(self, length: UVec, reflection=1.0) -> float:
|
||||
"""Returns magnitude of `length` direction vector transformed from
|
||||
old OCS into new OCS including `reflection` correction applied.
|
||||
"""
|
||||
return self.m.transform_direction(self.old_ocs.to_wcs(length)).magnitude * sign(
|
||||
reflection
|
||||
)
|
||||
|
||||
def transform_width(self, width: float) -> float:
|
||||
"""Transform the width of a linear OCS entity from the old OCS
|
||||
into the new OCS. (LWPOLYLINE!)
|
||||
"""
|
||||
abs_width = abs(width)
|
||||
if abs_width > 1e-12: # assume a uniform scaling!
|
||||
return max(
|
||||
self.transform_length((abs_width, 0, 0)),
|
||||
self.transform_length((0, abs_width, 0)),
|
||||
)
|
||||
return 0.0
|
||||
|
||||
transform_scale_factor = transform_length
|
||||
|
||||
def transform_ocs_direction(self, direction: Vec3) -> Vec3:
|
||||
"""Transform an OCS direction from the old OCS into the new OCS."""
|
||||
# OCS origin is ALWAYS the WCS origin!
|
||||
old_wcs_direction = self.old_ocs.to_wcs(direction)
|
||||
new_wcs_direction = self.m.transform_direction(old_wcs_direction)
|
||||
return self.new_ocs.from_wcs(new_wcs_direction)
|
||||
|
||||
def transform_thickness(self, thickness: float) -> float:
|
||||
"""Transform the thickness attribute of an OCS entity from the old OCS
|
||||
into the new OCS.
|
||||
|
||||
Thickness is always applied in the z-axis direction of the OCS
|
||||
a.k.a. extrusion vector.
|
||||
|
||||
"""
|
||||
# Only the z-component of the thickness vector transformed into the
|
||||
# new OCS is relevant for the extrusion in the direction of the new
|
||||
# OCS z-axis.
|
||||
# Input and output thickness can be negative!
|
||||
new_ocs_thickness = self.transform_ocs_direction(Vec3(0, 0, thickness))
|
||||
return new_ocs_thickness.z
|
||||
|
||||
def transform_vertex(self, vertex: UVec) -> Vec3:
|
||||
"""Returns vertex transformed from old OCS into new OCS."""
|
||||
return self.new_ocs.from_wcs(self.m.transform(self.old_ocs.to_wcs(vertex)))
|
||||
|
||||
def transform_2d_vertex(self, vertex: UVec, elevation: float) -> Vec2:
|
||||
"""Returns 2D vertex transformed from old OCS into new OCS."""
|
||||
v = Vec3(vertex).replace(z=elevation)
|
||||
return Vec2(self.new_ocs.from_wcs(self.m.transform(self.old_ocs.to_wcs(v))))
|
||||
|
||||
def transform_direction(self, direction: UVec) -> Vec3:
|
||||
"""Returns direction transformed from old OCS into new OCS."""
|
||||
return self.new_ocs.from_wcs(
|
||||
self.m.transform_direction(self.old_ocs.to_wcs(direction))
|
||||
)
|
||||
|
||||
def transform_angle(self, angle: float) -> float:
|
||||
"""Returns angle (in radians) from old OCS transformed into new OCS."""
|
||||
return self.transform_direction(Vec3.from_angle(angle)).angle
|
||||
|
||||
def transform_deg_angle(self, angle: float) -> float:
|
||||
"""Returns angle (in degrees) from old OCS transformed into new OCS."""
|
||||
return self.transform_angle(angle * RADIANS) * DEG
|
||||
|
||||
def transform_ccw_arc_angles(self, start: float, end: float) -> tuple[float, float]:
|
||||
"""Returns arc start- and end angle (in radians) from old OCS
|
||||
transformed into new OCS in counter-clockwise orientation.
|
||||
"""
|
||||
old_angle_span = arc_angle_span_rad(start, end) # always >= 0
|
||||
new_start = self.transform_angle(start)
|
||||
new_end = self.transform_angle(end)
|
||||
if math.isclose(old_angle_span, math.pi): # semicircle
|
||||
old_angle_span = 1.0 # arbitrary angle span
|
||||
check = self.transform_angle(start + old_angle_span)
|
||||
new_angle_span = arc_angle_span_rad(new_start, check)
|
||||
elif math.isclose(old_angle_span, math.tau):
|
||||
# preserve full circle span
|
||||
return new_start, new_start + math.tau
|
||||
else:
|
||||
new_angle_span = arc_angle_span_rad(new_start, new_end)
|
||||
|
||||
# 2022-07-07: relative tolerance reduced from 1e-9 to 1e-8 for issue #702
|
||||
if math.isclose(old_angle_span, new_angle_span, rel_tol=1e-8):
|
||||
return new_start, new_end
|
||||
else: # reversed angle orientation
|
||||
return new_end, new_start
|
||||
|
||||
def transform_ccw_arc_angles_deg(
|
||||
self, start: float, end: float
|
||||
) -> tuple[float, float]:
|
||||
"""Returns start- and end angle (in degrees) from old OCS transformed
|
||||
into new OCS in counter-clockwise orientation.
|
||||
"""
|
||||
start, end = self.transform_ccw_arc_angles(start * RADIANS, end * RADIANS)
|
||||
return start * DEG, end * DEG
|
||||
|
||||
def transform_scale_vector(self, vec: Vec3) -> Vec3:
|
||||
ocs = self.old_ocs
|
||||
ux, uy, uz = self.m.transform_directions((ocs.ux, ocs.uy, ocs.uz))
|
||||
x_scale = ux.magnitude * vec.x
|
||||
y_scale = uy.magnitude * vec.y
|
||||
z_scale = uz.magnitude * vec.z
|
||||
expected_uy = uz.cross(ux).normalize()
|
||||
if not expected_uy.isclose(uy.normalize(), abs_tol=1e-12):
|
||||
# new y-axis points into opposite direction:
|
||||
y_scale = -y_scale
|
||||
return Vec3(x_scale, y_scale, z_scale)
|
||||
|
||||
|
||||
class WCSTransform:
|
||||
def __init__(self, m: Matrix44):
|
||||
self.m = m
|
||||
new_x = m.transform_direction(X_AXIS)
|
||||
new_y = m.transform_direction(Y_AXIS)
|
||||
new_z = m.transform_direction(Z_AXIS)
|
||||
new_x_mag_squ = new_x.magnitude_square
|
||||
self.has_uniform_xy_scaling = math.isclose(
|
||||
new_x_mag_squ, new_y.magnitude_square
|
||||
)
|
||||
self.has_uniform_xyz_scaling = self.has_uniform_xy_scaling and math.isclose(
|
||||
new_x_mag_squ, new_z.magnitude_square
|
||||
)
|
||||
self.uniform_scale = self.transform_length(1.0)
|
||||
|
||||
def transform_length(self, value: float, axis: str = "x") -> float:
|
||||
if axis == "x":
|
||||
v = Vec3(value, 0, 0)
|
||||
elif axis == "y":
|
||||
v = Vec3(0, value, 0)
|
||||
elif axis == "z":
|
||||
v = Vec3(0, 0, value)
|
||||
else:
|
||||
raise ValueError(f"invalid axis '{axis}'")
|
||||
return self.m.transform_direction(v).magnitude
|
||||
|
||||
|
||||
class InsertCoordinateSystem:
|
||||
def __init__(
|
||||
self,
|
||||
insert: UVec,
|
||||
scale: tuple[float, float, float],
|
||||
rotation: float,
|
||||
extrusion: UVec,
|
||||
):
|
||||
"""Defines an INSERT coordinate system.
|
||||
|
||||
Args:
|
||||
insert: insertion location
|
||||
scale: scaling factors for x-, y- and z-axis
|
||||
rotation: rotation angle around the extrusion vector in degrees
|
||||
extrusion: extrusion vector which defines the :ref:`OCS`
|
||||
|
||||
"""
|
||||
self.insert = Vec3(insert)
|
||||
self.scale_factor_x = float(scale[0])
|
||||
self.scale_factor_y = float(scale[1])
|
||||
self.scale_factor_z = float(scale[2])
|
||||
self.rotation = float(rotation)
|
||||
self.extrusion = Vec3(extrusion)
|
||||
|
||||
def transform(self, m: Matrix44, tol=1e-9) -> InsertCoordinateSystem:
|
||||
"""Returns the transformed INSERT coordinate system.
|
||||
|
||||
Args:
|
||||
m: transformation matrix
|
||||
tol: tolerance value
|
||||
|
||||
"""
|
||||
ocs = OCS(self.extrusion)
|
||||
|
||||
# Transform source OCS axis into the target coordinate system:
|
||||
ux, uy, uz = m.transform_directions((ocs.ux, ocs.uy, ocs.uz))
|
||||
|
||||
# Calculate new axis scaling factors:
|
||||
x_scale = ux.magnitude * self.scale_factor_x
|
||||
y_scale = uy.magnitude * self.scale_factor_y
|
||||
z_scale = uz.magnitude * self.scale_factor_z
|
||||
|
||||
ux = ux.normalize()
|
||||
uy = uy.normalize()
|
||||
uz = uz.normalize()
|
||||
# check for orthogonal x-, y- and z-axis
|
||||
if abs(ux.dot(uz)) > tol or abs(ux.dot(uy)) > tol or abs(uz.dot(uy)) > tol:
|
||||
raise InsertTransformationError("Non-orthogonal target system.")
|
||||
|
||||
# expected y-axis for an orthogonal right-handed coordinate system:
|
||||
expected_uy = uz.cross(ux)
|
||||
if not expected_uy.isclose(uy, abs_tol=tol):
|
||||
# new y-axis points into opposite direction:
|
||||
y_scale = -y_scale
|
||||
|
||||
ocs_transform = OCSTransform.from_ocs(OCS(self.extrusion), OCS(uz), m)
|
||||
return InsertCoordinateSystem(
|
||||
insert=ocs_transform.transform_vertex(self.insert),
|
||||
scale=(x_scale, y_scale, z_scale),
|
||||
rotation=ocs_transform.transform_deg_angle(self.rotation),
|
||||
extrusion=uz,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2022-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, Iterator, Sequence
|
||||
import ezdxf
|
||||
from ezdxf.math import Vec2, UVec, Vec3, safe_normal_vector, OCS
|
||||
|
||||
from ._mapbox_earcut import earcut
|
||||
|
||||
if ezdxf.options.use_c_ext:
|
||||
try:
|
||||
from ezdxf.acc.mapbox_earcut import earcut # type: ignore
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = [
|
||||
"mapbox_earcut_2d",
|
||||
"mapbox_earcut_3d",
|
||||
]
|
||||
|
||||
|
||||
def mapbox_earcut_2d(
|
||||
exterior: Iterable[UVec], holes: Iterable[Iterable[UVec]] | None = None
|
||||
) -> list[Sequence[Vec2]]:
|
||||
"""Mapbox triangulation algorithm with hole support for 2D polygons.
|
||||
|
||||
Implements a modified ear slicing algorithm, optimized by z-order
|
||||
curve hashing and extended to handle holes, twisted polygons, degeneracies
|
||||
and self-intersections in a way that doesn't guarantee correctness of
|
||||
triangulation, but attempts to always produce acceptable results for
|
||||
practical data.
|
||||
|
||||
Source: https://github.com/mapbox/earcut
|
||||
|
||||
Args:
|
||||
exterior: exterior polygon as iterable of :class:`Vec2` objects
|
||||
holes: iterable of holes as iterable of :class:`Vec2` objects, a hole
|
||||
with single point represents a `Steiner point`_.
|
||||
|
||||
Returns:
|
||||
yields the result as 3-tuples of :class:`Vec2` objects
|
||||
|
||||
.. _Steiner point: https://en.wikipedia.org/wiki/Steiner_point_(computational_geometry)
|
||||
|
||||
"""
|
||||
points = Vec2.list(exterior)
|
||||
if len(points) == 0:
|
||||
return []
|
||||
holes_: list[list[Vec2]] = []
|
||||
if holes:
|
||||
holes_ = [Vec2.list(hole) for hole in holes]
|
||||
return earcut(points, holes_)
|
||||
|
||||
|
||||
def mapbox_earcut_3d(
|
||||
exterior: Iterable[UVec], holes: Iterable[Iterable[UVec]] | None = None
|
||||
) -> Iterator[Sequence[Vec3]]:
|
||||
"""Mapbox triangulation algorithm with hole support for flat 3D polygons.
|
||||
|
||||
Implements a modified ear slicing algorithm, optimized by z-order
|
||||
curve hashing and extended to handle holes, twisted polygons, degeneracies
|
||||
and self-intersections in a way that doesn't guarantee correctness of
|
||||
triangulation, but attempts to always produce acceptable results for
|
||||
practical data.
|
||||
|
||||
Source: https://github.com/mapbox/earcut
|
||||
|
||||
Args:
|
||||
exterior: exterior polygon as iterable of :class:`Vec3` objects
|
||||
holes: iterable of holes as iterable of :class:`Vec3` objects, a hole
|
||||
with single point represents a `Steiner point`_.
|
||||
|
||||
Returns:
|
||||
yields the result as 3-tuples of :class:`Vec3` objects
|
||||
|
||||
Raise:
|
||||
TypeError: invalid input data type
|
||||
ZeroDivisionError: normal vector calculation failed
|
||||
|
||||
"""
|
||||
polygon = Vec3.list(exterior)
|
||||
if len(polygon) == 0:
|
||||
return
|
||||
|
||||
if polygon[0].isclose(polygon[-1]):
|
||||
polygon.pop()
|
||||
count = len(polygon)
|
||||
if count < 3:
|
||||
return
|
||||
if count == 3:
|
||||
yield polygon[0], polygon[1], polygon[2]
|
||||
return
|
||||
|
||||
ocs = OCS(safe_normal_vector(polygon))
|
||||
elevation = ocs.from_wcs(polygon[0]).z
|
||||
exterior_ocs = list(ocs.points_from_wcs(polygon))
|
||||
holes_ocs: list[list[Vec3]] = []
|
||||
if holes:
|
||||
holes_ocs = [list(ocs.points_from_wcs(hole)) for hole in holes]
|
||||
|
||||
# Vec3 supports the _Point protocol in _mapbox_earcut.py
|
||||
# required attributes: x, y
|
||||
for triangle in earcut(exterior_ocs, holes_ocs):
|
||||
yield tuple(
|
||||
ocs.points_to_wcs(Vec3(v.x, v.y, elevation) for v in triangle)
|
||||
)
|
||||
@@ -0,0 +1,517 @@
|
||||
# Copyright (c) 2018-2024 Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Sequence, Iterable, Optional, Iterator
|
||||
from ezdxf.math import Vec3, UVec, X_AXIS, Y_AXIS, Z_AXIS, Matrix44
|
||||
from ezdxf.colors import RGB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ezdxf.layouts import BaseLayout
|
||||
|
||||
__all__ = ["OCS", "UCS", "PassTroughUCS"]
|
||||
|
||||
|
||||
def render_axis(
|
||||
layout: BaseLayout,
|
||||
start: UVec,
|
||||
points: Sequence[UVec],
|
||||
colors: RGB = RGB(1, 3, 5),
|
||||
) -> None:
|
||||
for point, color in zip(points, colors):
|
||||
layout.add_line(start, point, dxfattribs={"color": color})
|
||||
|
||||
|
||||
_1_OVER_64 = 1.0 / 64.0
|
||||
|
||||
|
||||
class OCS:
|
||||
"""Establish an :ref:`OCS` for a given extrusion vector.
|
||||
|
||||
Args:
|
||||
extrusion: extrusion vector.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, extrusion: UVec = Z_AXIS):
|
||||
Az = Vec3(extrusion).normalize()
|
||||
self.transform = not Az.isclose(Z_AXIS)
|
||||
if self.transform:
|
||||
if (abs(Az.x) < _1_OVER_64) and (abs(Az.y) < _1_OVER_64):
|
||||
Ax = Y_AXIS.cross(Az)
|
||||
else:
|
||||
Ax = Z_AXIS.cross(Az)
|
||||
Ax = Ax.normalize()
|
||||
Ay = Az.cross(Ax).normalize()
|
||||
self.matrix = Matrix44.ucs(Ax, Ay, Az)
|
||||
|
||||
@property
|
||||
def ux(self) -> Vec3:
|
||||
"""x-axis unit vector"""
|
||||
return self.matrix.ux if self.transform else X_AXIS
|
||||
|
||||
@property
|
||||
def uy(self) -> Vec3:
|
||||
"""y-axis unit vector"""
|
||||
return self.matrix.uy if self.transform else Y_AXIS
|
||||
|
||||
@property
|
||||
def uz(self) -> Vec3:
|
||||
"""z-axis unit vector"""
|
||||
return self.matrix.uz if self.transform else Z_AXIS
|
||||
|
||||
def from_wcs(self, point: UVec) -> Vec3:
|
||||
"""Returns OCS vector for WCS `point`."""
|
||||
p3 = Vec3(point)
|
||||
if self.transform:
|
||||
return self.matrix.ocs_from_wcs(p3)
|
||||
else:
|
||||
return p3
|
||||
|
||||
def points_from_wcs(self, points: Iterable[UVec]) -> Iterator[Vec3]:
|
||||
"""Returns iterable of OCS vectors from WCS `points`."""
|
||||
_points = Vec3.generate(points)
|
||||
if self.transform:
|
||||
from_wcs = self.matrix.ocs_from_wcs
|
||||
for point in _points:
|
||||
yield from_wcs(point)
|
||||
else:
|
||||
yield from _points
|
||||
|
||||
def to_wcs(self, point: UVec) -> Vec3:
|
||||
"""Returns WCS vector for OCS `point`."""
|
||||
if self.transform:
|
||||
return self.matrix.ocs_to_wcs(point)
|
||||
else:
|
||||
return Vec3(point)
|
||||
|
||||
def points_to_wcs(self, points: Iterable[UVec]) -> Iterator[Vec3]:
|
||||
"""Returns iterable of WCS vectors for OCS `points`."""
|
||||
_points = Vec3.generate(points)
|
||||
if self.transform:
|
||||
to_wcs = self.matrix.ocs_to_wcs
|
||||
for point in _points:
|
||||
yield to_wcs(point)
|
||||
else:
|
||||
yield from _points
|
||||
|
||||
def render_axis(
|
||||
self,
|
||||
layout: BaseLayout,
|
||||
length: float = 1,
|
||||
colors: RGB = RGB(1, 3, 5),
|
||||
) -> None:
|
||||
"""Render axis as 3D lines into a `layout`."""
|
||||
render_axis(
|
||||
layout,
|
||||
start=(0, 0, 0),
|
||||
points=(
|
||||
self.to_wcs(X_AXIS * length),
|
||||
self.to_wcs(Y_AXIS * length),
|
||||
self.to_wcs(Z_AXIS * length),
|
||||
),
|
||||
colors=colors,
|
||||
)
|
||||
|
||||
|
||||
class UCS:
|
||||
"""Establish a user coordinate system (:ref:`UCS`). The UCS is defined by
|
||||
the origin and two unit vectors for the x-, y- or z-axis, all axis in
|
||||
:ref:`WCS`. The missing axis is the cross product of the given axis.
|
||||
|
||||
If x- and y-axis are ``None``: ux = ``(1, 0, 0)``, uy = ``(0, 1, 0)``,
|
||||
uz = ``(0, 0, 1)``.
|
||||
|
||||
Unit vectors don't have to be normalized, normalization is done at
|
||||
initialization, this is also the reason why scaling gets lost by copying or
|
||||
rotating.
|
||||
|
||||
Args:
|
||||
origin: defines the UCS origin in world coordinates
|
||||
ux: defines the UCS x-axis as vector in :ref:`WCS`
|
||||
uy: defines the UCS y-axis as vector in :ref:`WCS`
|
||||
uz: defines the UCS z-axis as vector in :ref:`WCS`
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
origin: UVec = (0, 0, 0),
|
||||
ux: Optional[UVec] = None,
|
||||
uy: Optional[UVec] = None,
|
||||
uz: Optional[UVec] = None,
|
||||
):
|
||||
if ux is None and uy is None:
|
||||
_ux: Vec3 = X_AXIS
|
||||
_uy: Vec3 = Y_AXIS
|
||||
_uz: Vec3 = Z_AXIS
|
||||
elif ux is None:
|
||||
_uy = Vec3(uy).normalize()
|
||||
_uz = Vec3(uz).normalize()
|
||||
_ux = Vec3(uy).cross(uz).normalize()
|
||||
elif uy is None:
|
||||
_ux = Vec3(ux).normalize()
|
||||
_uz = Vec3(uz).normalize()
|
||||
_uy = Vec3(uz).cross(ux).normalize()
|
||||
elif uz is None:
|
||||
_ux = Vec3(ux).normalize()
|
||||
_uy = Vec3(uy).normalize()
|
||||
_uz = Vec3(ux).cross(uy).normalize()
|
||||
else: # all axis are given
|
||||
_ux = Vec3(ux).normalize()
|
||||
_uy = Vec3(uy).normalize()
|
||||
_uz = Vec3(uz).normalize()
|
||||
|
||||
self.matrix: Matrix44 = Matrix44.ucs(_ux, _uy, _uz, Vec3(origin))
|
||||
|
||||
@property
|
||||
def ux(self) -> Vec3:
|
||||
"""x-axis unit vector"""
|
||||
return self.matrix.ux
|
||||
|
||||
@property
|
||||
def uy(self) -> Vec3:
|
||||
"""y-axis unit vector"""
|
||||
return self.matrix.uy
|
||||
|
||||
@property
|
||||
def uz(self) -> Vec3:
|
||||
"""z-axis unit vector"""
|
||||
return self.matrix.uz
|
||||
|
||||
@property
|
||||
def origin(self) -> Vec3:
|
||||
"""Returns the origin"""
|
||||
return self.matrix.origin
|
||||
|
||||
@origin.setter
|
||||
def origin(self, v: UVec) -> None:
|
||||
"""Set origin."""
|
||||
self.matrix.origin = v
|
||||
|
||||
def copy(self) -> UCS:
|
||||
"""Returns a copy of this UCS."""
|
||||
return UCS(self.origin, self.ux, self.uy, self.uz)
|
||||
|
||||
def to_wcs(self, point: Vec3) -> Vec3:
|
||||
"""Returns WCS point for UCS `point`."""
|
||||
return self.matrix.transform(point)
|
||||
|
||||
def points_to_wcs(self, points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
"""Returns iterable of WCS vectors for UCS `points`."""
|
||||
return self.matrix.transform_vertices(points)
|
||||
|
||||
def direction_to_wcs(self, vector: Vec3) -> Vec3:
|
||||
"""Returns WCS direction for UCS `vector` without origin adjustment."""
|
||||
return self.matrix.transform_direction(vector)
|
||||
|
||||
def from_wcs(self, point: Vec3) -> Vec3:
|
||||
"""Returns UCS point for WCS `point`."""
|
||||
return self.matrix.ucs_vertex_from_wcs(point)
|
||||
|
||||
def points_from_wcs(self, points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
"""Returns iterable of UCS vectors from WCS `points`."""
|
||||
from_wcs = self.from_wcs
|
||||
for point in points:
|
||||
yield from_wcs(point)
|
||||
|
||||
def direction_from_wcs(self, vector: Vec3) -> Vec3:
|
||||
"""Returns UCS vector for WCS `vector` without origin adjustment."""
|
||||
return self.matrix.ucs_direction_from_wcs(vector)
|
||||
|
||||
def to_ocs(self, point: Vec3) -> Vec3:
|
||||
"""Returns OCS vector for UCS `point`.
|
||||
|
||||
The :class:`OCS` is defined by the z-axis of the :class:`UCS`.
|
||||
|
||||
"""
|
||||
wpoint = self.to_wcs(point)
|
||||
return OCS(self.uz).from_wcs(wpoint)
|
||||
|
||||
def points_to_ocs(self, points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
"""Returns iterable of OCS vectors for UCS `points`.
|
||||
|
||||
The :class:`OCS` is defined by the z-axis of the :class:`UCS`.
|
||||
|
||||
Args:
|
||||
points: iterable of UCS vertices
|
||||
|
||||
"""
|
||||
wcs = self.to_wcs
|
||||
ocs = OCS(self.uz)
|
||||
for point in points:
|
||||
yield ocs.from_wcs(wcs(point))
|
||||
|
||||
def to_ocs_angle_deg(self, angle: float) -> float:
|
||||
"""Transforms `angle` from current UCS to the parent coordinate system
|
||||
(most likely the WCS) including the transformation to the OCS
|
||||
established by the extrusion vector :attr:`UCS.uz`.
|
||||
|
||||
Args:
|
||||
angle: in UCS in degrees
|
||||
|
||||
"""
|
||||
return self.ucs_direction_to_ocs_direction(
|
||||
Vec3.from_deg_angle(angle)
|
||||
).angle_deg
|
||||
|
||||
def to_ocs_angle_rad(self, angle: float) -> float:
|
||||
"""Transforms `angle` from current UCS to the parent coordinate system
|
||||
(most likely the WCS) including the transformation to the OCS
|
||||
established by the extrusion vector :attr:`UCS.uz`.
|
||||
|
||||
Args:
|
||||
angle: in UCS in radians
|
||||
|
||||
"""
|
||||
return self.ucs_direction_to_ocs_direction(Vec3.from_angle(angle)).angle
|
||||
|
||||
def ucs_direction_to_ocs_direction(self, direction: Vec3) -> Vec3:
|
||||
"""Transforms UCS `direction` vector into OCS direction vector of the
|
||||
parent coordinate system (most likely the WCS), target OCS is defined by
|
||||
the UCS z-axis.
|
||||
"""
|
||||
return OCS(self.uz).from_wcs(self.direction_to_wcs(direction))
|
||||
|
||||
def rotate(self, axis: UVec, angle: float) -> UCS:
|
||||
"""Returns a new rotated UCS, with the same origin as the source UCS.
|
||||
The rotation vector is located in the origin and has :ref:`WCS`
|
||||
coordinates e.g. (0, 0, 1) is the WCS z-axis as rotation vector.
|
||||
|
||||
Args:
|
||||
axis: arbitrary rotation axis as vector in :ref:`WCS`
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
t = Matrix44.axis_rotate(Vec3(axis), angle)
|
||||
ux, uy, uz = t.transform_vertices([self.ux, self.uy, self.uz])
|
||||
return UCS(origin=self.origin, ux=ux, uy=uy, uz=uz)
|
||||
|
||||
def rotate_local_x(self, angle: float) -> UCS:
|
||||
"""Returns a new rotated UCS, rotation axis is the local x-axis.
|
||||
|
||||
Args:
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
t = Matrix44.axis_rotate(self.ux, angle)
|
||||
uy, uz = t.transform_vertices([self.uy, self.uz])
|
||||
return UCS(origin=self.origin, ux=self.ux, uy=uy, uz=uz)
|
||||
|
||||
def rotate_local_y(self, angle: float) -> UCS:
|
||||
"""Returns a new rotated UCS, rotation axis is the local y-axis.
|
||||
|
||||
Args:
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
t = Matrix44.axis_rotate(self.uy, angle)
|
||||
ux, uz = t.transform_vertices([self.ux, self.uz])
|
||||
return UCS(origin=self.origin, ux=ux, uy=self.uy, uz=uz)
|
||||
|
||||
def rotate_local_z(self, angle: float) -> UCS:
|
||||
"""Returns a new rotated UCS, rotation axis is the local z-axis.
|
||||
|
||||
Args:
|
||||
angle: rotation angle in radians
|
||||
|
||||
"""
|
||||
t = Matrix44.axis_rotate(self.uz, angle)
|
||||
ux, uy = t.transform_vertices([self.ux, self.uy])
|
||||
return UCS(origin=self.origin, ux=ux, uy=uy, uz=self.uz)
|
||||
|
||||
def shift(self, delta: UVec) -> UCS:
|
||||
"""Shifts current UCS by `delta` vector and returns `self`.
|
||||
|
||||
Args:
|
||||
delta: shifting vector
|
||||
|
||||
"""
|
||||
self.origin += Vec3(delta)
|
||||
return self
|
||||
|
||||
def moveto(self, location: UVec) -> UCS:
|
||||
"""Place current UCS at new origin `location` and returns `self`.
|
||||
|
||||
Args:
|
||||
location: new origin in WCS
|
||||
|
||||
"""
|
||||
self.origin = Vec3(location)
|
||||
return self
|
||||
|
||||
def transform(self, m: Matrix44) -> UCS:
|
||||
"""General inplace transformation interface, returns `self` (floating
|
||||
interface).
|
||||
|
||||
Args:
|
||||
m: 4x4 transformation matrix (:class:`ezdxf.math.Matrix44`)
|
||||
|
||||
"""
|
||||
self.matrix *= m
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_cartesian(self) -> bool:
|
||||
"""Returns ``True`` if cartesian coordinate system."""
|
||||
return self.matrix.is_cartesian
|
||||
|
||||
@staticmethod
|
||||
def from_x_axis_and_point_in_xy(
|
||||
origin: UVec, axis: UVec, point: UVec
|
||||
) -> UCS:
|
||||
"""Returns a new :class:`UCS` defined by the origin, the x-axis vector
|
||||
and an arbitrary point in the xy-plane.
|
||||
|
||||
Args:
|
||||
origin: UCS origin as (x, y, z) tuple in :ref:`WCS`
|
||||
axis: x-axis vector as (x, y, z) tuple in :ref:`WCS`
|
||||
point: arbitrary point unlike the origin in the xy-plane as
|
||||
(x, y, z) tuple in :ref:`WCS`
|
||||
|
||||
"""
|
||||
x_axis = Vec3(axis)
|
||||
z_axis = x_axis.cross(Vec3(point) - origin)
|
||||
return UCS(origin=origin, ux=x_axis, uz=z_axis)
|
||||
|
||||
@staticmethod
|
||||
def from_x_axis_and_point_in_xz(
|
||||
origin: UVec, axis: UVec, point: UVec
|
||||
) -> UCS:
|
||||
"""Returns a new :class:`UCS` defined by the origin, the x-axis vector
|
||||
and an arbitrary point in the xz-plane.
|
||||
|
||||
Args:
|
||||
origin: UCS origin as (x, y, z) tuple in :ref:`WCS`
|
||||
axis: x-axis vector as (x, y, z) tuple in :ref:`WCS`
|
||||
point: arbitrary point unlike the origin in the xz-plane as
|
||||
(x, y, z) tuple in :ref:`WCS`
|
||||
|
||||
"""
|
||||
x_axis = Vec3(axis)
|
||||
xz_vector = Vec3(point) - origin
|
||||
y_axis = xz_vector.cross(x_axis)
|
||||
return UCS(origin=origin, ux=x_axis, uy=y_axis)
|
||||
|
||||
@staticmethod
|
||||
def from_y_axis_and_point_in_xy(
|
||||
origin: UVec, axis: UVec, point: UVec
|
||||
) -> UCS:
|
||||
"""Returns a new :class:`UCS` defined by the origin, the y-axis vector
|
||||
and an arbitrary point in the xy-plane.
|
||||
|
||||
Args:
|
||||
origin: UCS origin as (x, y, z) tuple in :ref:`WCS`
|
||||
axis: y-axis vector as (x, y, z) tuple in :ref:`WCS`
|
||||
point: arbitrary point unlike the origin in the xy-plane as
|
||||
(x, y, z) tuple in :ref:`WCS`
|
||||
|
||||
"""
|
||||
y_axis = Vec3(axis)
|
||||
xy_vector = Vec3(point) - origin
|
||||
z_axis = xy_vector.cross(y_axis)
|
||||
return UCS(origin=origin, uy=y_axis, uz=z_axis)
|
||||
|
||||
@staticmethod
|
||||
def from_y_axis_and_point_in_yz(
|
||||
origin: UVec, axis: UVec, point: UVec
|
||||
) -> UCS:
|
||||
"""Returns a new :class:`UCS` defined by the origin, the y-axis vector
|
||||
and an arbitrary point in the yz-plane.
|
||||
|
||||
Args:
|
||||
origin: UCS origin as (x, y, z) tuple in :ref:`WCS`
|
||||
axis: y-axis vector as (x, y, z) tuple in :ref:`WCS`
|
||||
point: arbitrary point unlike the origin in the yz-plane as
|
||||
(x, y, z) tuple in :ref:`WCS`
|
||||
|
||||
"""
|
||||
y_axis = Vec3(axis)
|
||||
yz_vector = Vec3(point) - origin
|
||||
x_axis = yz_vector.cross(y_axis)
|
||||
return UCS(origin=origin, ux=x_axis, uy=y_axis)
|
||||
|
||||
@staticmethod
|
||||
def from_z_axis_and_point_in_xz(
|
||||
origin: UVec, axis: UVec, point: UVec
|
||||
) -> UCS:
|
||||
"""Returns a new :class:`UCS` defined by the origin, the z-axis vector
|
||||
and an arbitrary point in the xz-plane.
|
||||
|
||||
Args:
|
||||
origin: UCS origin as (x, y, z) tuple in :ref:`WCS`
|
||||
axis: z-axis vector as (x, y, z) tuple in :ref:`WCS`
|
||||
point: arbitrary point unlike the origin in the xz-plane as
|
||||
(x, y, z) tuple in :ref:`WCS`
|
||||
|
||||
"""
|
||||
z_axis = Vec3(axis)
|
||||
y_axis = z_axis.cross(Vec3(point) - origin)
|
||||
return UCS(origin=origin, uy=y_axis, uz=z_axis)
|
||||
|
||||
@staticmethod
|
||||
def from_z_axis_and_point_in_yz(
|
||||
origin: UVec, axis: UVec, point: UVec
|
||||
) -> UCS:
|
||||
"""Returns a new :class:`UCS` defined by the origin, the z-axis vector
|
||||
and an arbitrary point in the yz-plane.
|
||||
|
||||
Args:
|
||||
origin: UCS origin as (x, y, z) tuple in :ref:`WCS`
|
||||
axis: z-axis vector as (x, y, z) tuple in :ref:`WCS`
|
||||
point: arbitrary point unlike the origin in the yz-plane as
|
||||
(x, y, z) tuple in :ref:`WCS`
|
||||
|
||||
"""
|
||||
z_axis = Vec3(axis)
|
||||
yz_vector = Vec3(point) - origin
|
||||
x_axis = yz_vector.cross(z_axis)
|
||||
return UCS(origin=origin, ux=x_axis, uz=z_axis)
|
||||
|
||||
def render_axis(
|
||||
self,
|
||||
layout: BaseLayout,
|
||||
length: float = 1,
|
||||
colors: RGB = RGB(1, 3, 5),
|
||||
):
|
||||
"""Render axis as 3D lines into a `layout`."""
|
||||
render_axis(
|
||||
layout,
|
||||
start=self.origin,
|
||||
points=(
|
||||
self.to_wcs(X_AXIS * length),
|
||||
self.to_wcs(Y_AXIS * length),
|
||||
self.to_wcs(Z_AXIS * length),
|
||||
),
|
||||
colors=colors,
|
||||
)
|
||||
|
||||
|
||||
class PassTroughUCS(UCS):
|
||||
"""UCS is equal to the WCS and OCS (extrusion = 0, 0, 1)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def to_wcs(self, point: Vec3) -> Vec3:
|
||||
return point
|
||||
|
||||
def points_to_wcs(self, points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
return iter(points)
|
||||
|
||||
def to_ocs(self, point: Vec3) -> Vec3:
|
||||
return point
|
||||
|
||||
def points_to_ocs(self, points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
return iter(points)
|
||||
|
||||
def to_ocs_angle_deg(self, angle: float) -> float:
|
||||
return angle
|
||||
|
||||
def to_ocs_angle_rad(self, angle: float) -> float:
|
||||
return angle
|
||||
|
||||
def from_wcs(self, point: Vec3) -> Vec3:
|
||||
return point
|
||||
|
||||
def points_from_wcs(self, points: Iterable[Vec3]) -> Iterator[Vec3]:
|
||||
return iter(points)
|
||||
Reference in New Issue
Block a user