Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/math/ellipse.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

600 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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