321 lines
11 KiB
Python
321 lines
11 KiB
Python
# Copyright (c) 2019-2022 Manfred Moitzi
|
|
# License: MIT License
|
|
from __future__ import annotations
|
|
from typing import TYPE_CHECKING, Iterable, Optional
|
|
import math
|
|
from ezdxf.audit import AuditError
|
|
from ezdxf.lldxf import validator
|
|
from ezdxf.math import (
|
|
Vec3,
|
|
Matrix44,
|
|
NULLVEC,
|
|
X_AXIS,
|
|
Z_AXIS,
|
|
ellipse,
|
|
ConstructionEllipse,
|
|
OCS,
|
|
)
|
|
from ezdxf.lldxf.attributes import (
|
|
DXFAttr,
|
|
DXFAttributes,
|
|
DefSubclass,
|
|
XType,
|
|
RETURN_DEFAULT,
|
|
group_code_mapping,
|
|
merge_group_code_mappings,
|
|
)
|
|
from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000
|
|
from .dxfentity import base_class, SubclassProcessor
|
|
from .dxfgfx import (
|
|
DXFGraphic,
|
|
acdb_entity,
|
|
add_entity,
|
|
replace_entity,
|
|
acdb_entity_group_codes,
|
|
)
|
|
from .factory import register_entity
|
|
|
|
if TYPE_CHECKING:
|
|
from ezdxf.lldxf.tagwriter import AbstractTagWriter
|
|
from ezdxf.entities import DXFNamespace, Spline
|
|
from ezdxf.audit import Auditor
|
|
|
|
|
|
__all__ = ["Ellipse"]
|
|
|
|
MIN_RATIO = 1e-10 # tested with DWG TrueView 2022
|
|
MAX_RATIO = 1.0 # tested with DWG TrueView 2022
|
|
TOL = 1e-9 # arbitrary choice
|
|
|
|
|
|
def is_valid_ratio(ratio: float) -> bool:
|
|
"""Check if axis-ratio is in valid range, takes an upper bound tolerance into
|
|
account for floating point imprecision.
|
|
"""
|
|
return MIN_RATIO <= abs(ratio) < (MAX_RATIO + TOL)
|
|
|
|
|
|
def clamp_axis_ratio(ratio: float) -> float:
|
|
"""Clamp axis-ratio into valid range and remove possible floating point imprecision.
|
|
"""
|
|
sign = -1 if ratio < 0 else +1
|
|
ratio = abs(ratio)
|
|
if ratio < MIN_RATIO:
|
|
return MIN_RATIO * sign
|
|
if ratio > MAX_RATIO:
|
|
return MAX_RATIO * sign
|
|
return ratio * sign
|
|
|
|
|
|
acdb_ellipse = DefSubclass(
|
|
"AcDbEllipse",
|
|
{
|
|
"center": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
|
|
# Major axis vector from 'center':
|
|
"major_axis": DXFAttr(
|
|
11,
|
|
xtype=XType.point3d,
|
|
default=X_AXIS,
|
|
validator=validator.is_not_null_vector,
|
|
),
|
|
# The extrusion vector does not establish an OCS, it is just the normal
|
|
# vector of the ellipse plane:
|
|
"extrusion": DXFAttr(
|
|
210,
|
|
xtype=XType.point3d,
|
|
default=Z_AXIS,
|
|
optional=True,
|
|
validator=validator.is_not_null_vector,
|
|
fixer=RETURN_DEFAULT,
|
|
),
|
|
# Ratio has to be in the range: -1.0 ... -1e-10 and +1e-10 ... +1.0:
|
|
"ratio": DXFAttr(
|
|
40, default=MAX_RATIO, validator=is_valid_ratio, fixer=clamp_axis_ratio
|
|
),
|
|
# Start of ellipse, this value is 0.0 for a full ellipse:
|
|
"start_param": DXFAttr(41, default=0),
|
|
# End of ellipse, this value is 2π for a full ellipse:
|
|
"end_param": DXFAttr(42, default=math.tau),
|
|
},
|
|
)
|
|
acdb_ellipse_group_code = group_code_mapping(acdb_ellipse)
|
|
merged_ellipse_group_codes = merge_group_code_mappings(
|
|
acdb_entity_group_codes, acdb_ellipse_group_code # type: ignore
|
|
)
|
|
|
|
HALF_PI = math.pi / 2.0
|
|
|
|
|
|
@register_entity
|
|
class Ellipse(DXFGraphic):
|
|
"""DXF ELLIPSE entity"""
|
|
|
|
DXFTYPE = "ELLIPSE"
|
|
DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_ellipse)
|
|
MIN_DXF_VERSION_FOR_EXPORT = DXF2000
|
|
|
|
def load_dxf_attribs(
|
|
self, processor: Optional[SubclassProcessor] = None
|
|
) -> DXFNamespace:
|
|
"""Loading interface. (internal API)"""
|
|
# bypass DXFGraphic, loading proxy graphic is skipped!
|
|
dxf = super(DXFGraphic, self).load_dxf_attribs(processor)
|
|
if processor:
|
|
processor.simple_dxfattribs_loader(dxf, merged_ellipse_group_codes)
|
|
return dxf
|
|
|
|
def export_entity(self, tagwriter: AbstractTagWriter) -> None:
|
|
"""Export entity specific data as DXF tags."""
|
|
super().export_entity(tagwriter)
|
|
tagwriter.write_tag2(SUBCLASS_MARKER, acdb_ellipse.name)
|
|
# is_valid_ratio() takes floating point imprecision on the upper bound of
|
|
# +/- 1.0 into account:
|
|
assert is_valid_ratio(
|
|
self.dxf.ratio
|
|
), f"axis-ratio out of range [{MIN_RATIO}, {MAX_RATIO}]"
|
|
self.dxf.ratio = clamp_axis_ratio(self.dxf.ratio)
|
|
self.dxf.export_dxf_attribs(
|
|
tagwriter,
|
|
[
|
|
"center",
|
|
"major_axis",
|
|
"extrusion",
|
|
"ratio",
|
|
"start_param",
|
|
"end_param",
|
|
],
|
|
)
|
|
|
|
@property
|
|
def minor_axis(self) -> Vec3:
|
|
dxf = self.dxf
|
|
return ellipse.minor_axis(Vec3(dxf.major_axis), Vec3(dxf.extrusion), dxf.ratio)
|
|
|
|
@property
|
|
def start_point(self) -> Vec3:
|
|
return list(self.vertices([self.dxf.start_param]))[0]
|
|
|
|
@property
|
|
def end_point(self) -> Vec3:
|
|
return list(self.vertices([self.dxf.end_param]))[0]
|
|
|
|
def construction_tool(self) -> ConstructionEllipse:
|
|
"""Returns construction tool :class:`ezdxf.math.ConstructionEllipse`."""
|
|
dxf = self.dxf
|
|
return ConstructionEllipse(
|
|
dxf.center,
|
|
dxf.major_axis,
|
|
dxf.extrusion,
|
|
dxf.ratio,
|
|
dxf.start_param,
|
|
dxf.end_param,
|
|
)
|
|
|
|
def apply_construction_tool(self, e: ConstructionEllipse) -> Ellipse:
|
|
"""Set ELLIPSE data from construction tool
|
|
:class:`ezdxf.math.ConstructionEllipse`.
|
|
|
|
"""
|
|
self.update_dxf_attribs(e.dxfattribs())
|
|
return self # floating interface
|
|
|
|
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 [0, 2π).
|
|
|
|
"""
|
|
start = self.dxf.start_param % math.tau
|
|
end = self.dxf.end_param % math.tau
|
|
yield from ellipse.get_params(start, end, 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 to 2π in radians,
|
|
param goes counter-clockwise around the extrusion vector,
|
|
major_axis = local x-axis = 0 rad.
|
|
|
|
"""
|
|
yield from self.construction_tool().vertices(params)
|
|
|
|
def flattening(self, distance: float, segments: int = 8) -> 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
|
|
where the start vertex has the same value as the end vertex.
|
|
|
|
Args:
|
|
distance: maximum distance from the projected curve point onto the
|
|
segment chord.
|
|
segments: minimum segment count
|
|
|
|
"""
|
|
return self.construction_tool().flattening(distance, segments)
|
|
|
|
def swap_axis(self):
|
|
"""Swap axis and adjust start- and end parameter."""
|
|
e = self.construction_tool()
|
|
e.swap_axis()
|
|
self.update_dxf_attribs(e.dxfattribs())
|
|
|
|
@classmethod
|
|
def from_arc(cls, entity: DXFGraphic) -> Ellipse:
|
|
"""Create a new virtual ELLIPSE entity from an ARC or a CIRCLE entity.
|
|
|
|
The new entity has no owner, no handle, is not stored in the entity database nor
|
|
assigned to any layout!
|
|
|
|
"""
|
|
assert entity.dxftype() in {"ARC", "CIRCLE"}, "ARC or CIRCLE entity required"
|
|
attribs = entity.dxfattribs(drop={"owner", "handle", "thickness"})
|
|
e = ellipse.ConstructionEllipse.from_arc(
|
|
center=attribs.get("center", NULLVEC),
|
|
extrusion=attribs.get("extrusion", Z_AXIS),
|
|
# Remove all not ELLIPSE attributes:
|
|
radius=attribs.pop("radius", 1.0),
|
|
start_angle=attribs.pop("start_angle", 0),
|
|
end_angle=attribs.pop("end_angle", 360),
|
|
)
|
|
attribs.update(e.dxfattribs())
|
|
return cls.new(dxfattribs=attribs, doc=entity.doc)
|
|
|
|
def transform(self, m: Matrix44) -> Ellipse:
|
|
"""Transform the ELLIPSE entity by transformation matrix `m` inplace."""
|
|
e = self.construction_tool()
|
|
e.transform(m)
|
|
self.update_dxf_attribs(e.dxfattribs())
|
|
self.post_transform(m)
|
|
return self
|
|
|
|
def translate(self, dx: float, dy: float, dz: float) -> Ellipse:
|
|
"""Optimized ELLIPSE translation about `dx` in x-axis, `dy` in y-axis
|
|
and `dz` in z-axis, returns `self` (floating interface).
|
|
|
|
"""
|
|
self.dxf.center = Vec3(dx, dy, dz) + self.dxf.center
|
|
# Avoid Matrix44 instantiation if not required:
|
|
if self.is_post_transform_required:
|
|
self.post_transform(Matrix44.translate(dx, dy, dz))
|
|
return self
|
|
|
|
def to_spline(self, replace=True) -> Spline:
|
|
"""Convert ELLIPSE to a :class:`~ezdxf.entities.Spline` entity.
|
|
|
|
Adds the new SPLINE entity to the entity database and to the
|
|
same layout as the source entity.
|
|
|
|
Args:
|
|
replace: replace (delete) source entity by SPLINE entity if ``True``
|
|
|
|
"""
|
|
from ezdxf.entities import Spline
|
|
|
|
spline = Spline.from_arc(self)
|
|
layout = self.get_layout()
|
|
assert layout is not None, "valid layout required"
|
|
if replace:
|
|
replace_entity(self, spline, layout)
|
|
else:
|
|
add_entity(spline, layout)
|
|
return spline
|
|
|
|
def ocs(self) -> OCS:
|
|
# WCS entity which supports the "extrusion" attribute in a
|
|
# different way!
|
|
return OCS()
|
|
|
|
def audit(self, auditor: Auditor) -> None:
|
|
if not self.is_alive:
|
|
return
|
|
super().audit(auditor)
|
|
entity = str(self)
|
|
major_axis = Vec3(self.dxf.major_axis)
|
|
if major_axis.is_null:
|
|
auditor.trash(self)
|
|
auditor.fixed_error(
|
|
code=AuditError.INVALID_MAJOR_AXIS,
|
|
message=f"Removed {entity} with invalid major axis: (0, 0, 0).",
|
|
)
|
|
return
|
|
axis_ratio = self.dxf.ratio
|
|
if is_valid_ratio(axis_ratio):
|
|
# remove possible floating point imprecision:
|
|
self.dxf.ratio = clamp_axis_ratio(axis_ratio)
|
|
return
|
|
if abs(axis_ratio) > MAX_RATIO:
|
|
self.swap_axis()
|
|
auditor.fixed_error(
|
|
code=AuditError.INVALID_ELLIPSE_RATIO,
|
|
message=f"Fixed invalid axis-ratio in {entity} by swapping axis.",
|
|
)
|
|
elif abs(axis_ratio) < MIN_RATIO:
|
|
self.dxf.ratio = clamp_axis_ratio(axis_ratio)
|
|
auditor.fixed_error(
|
|
code=AuditError.INVALID_ELLIPSE_RATIO,
|
|
message=f"Fixed invalid axis-ratio in {entity}, set to {MIN_RATIO}.",
|
|
)
|