refactor: excel parse

This commit is contained in:
Blizzard
2026-04-16 10:01:11 +08:00
parent 680ecc320f
commit f62f95ec02
7941 changed files with 2899112 additions and 0 deletions
@@ -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 Rytzs 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)