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

1389 lines
48 KiB
Python

# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Union, Iterable, Sequence, Optional, TYPE_CHECKING
import abc
import enum
import math
from ezdxf.lldxf import const
from ezdxf.lldxf.tags import Tags, group_tags
from ezdxf.math import (
Vec2,
Vec3,
UVec,
OCS,
bulge_to_arc,
ConstructionEllipse,
ConstructionArc,
BSpline,
NonUniformScalingError,
open_uniform_knot_vector,
global_bspline_interpolation,
arc_angle_span_deg,
angle_to_param,
param_to_angle,
)
from ezdxf.math.transformtools import OCSTransform
if TYPE_CHECKING:
from ezdxf.lldxf.tagwriter import AbstractTagWriter
__all__ = [
"BoundaryPaths",
"PolylinePath",
"EdgePath",
"LineEdge",
"ArcEdge",
"EllipseEdge",
"SplineEdge",
"EdgeType",
"BoundaryPathType",
"AbstractEdge",
"AbstractBoundaryPath",
]
@enum.unique
class BoundaryPathType(enum.IntEnum):
POLYLINE = 1
EDGE = 2
@enum.unique
class EdgeType(enum.IntEnum):
LINE = 1
ARC = 2
ELLIPSE = 3
SPLINE = 4
class AbstractBoundaryPath(abc.ABC):
type: BoundaryPathType
path_type_flags: int
source_boundary_objects: list[str]
@abc.abstractmethod
def clear(self) -> None: ...
@classmethod
@abc.abstractmethod
def load_tags(cls, tags: Tags) -> AbstractBoundaryPath: ...
@abc.abstractmethod
def export_dxf(self, tagwriter: AbstractTagWriter, dxftype: str) -> None: ...
@abc.abstractmethod
def transform(self, ocs: OCSTransform, elevation: float) -> None: ...
@abc.abstractmethod
def is_valid(self) -> bool: ...
class AbstractEdge(abc.ABC):
type: EdgeType
@property
@abc.abstractmethod
def start_point(self) -> Vec2: ...
@property
@abc.abstractmethod
def end_point(self) -> Vec2: ...
@abc.abstractmethod
def export_dxf(self, tagwriter: AbstractTagWriter) -> None: ...
@abc.abstractmethod
def transform(self, ocs: OCSTransform, elevation: float) -> None: ...
@abc.abstractmethod
def is_valid(self) -> bool: ...
class BoundaryPaths:
def __init__(self, paths: Optional[list[AbstractBoundaryPath]] = None):
self.paths: list[AbstractBoundaryPath] = paths or []
def __len__(self):
return len(self.paths)
def __getitem__(self, item):
return self.paths[item]
def __iter__(self):
return iter(self.paths)
def is_valid(self) -> bool:
return all(p.is_valid() for p in self.paths)
@classmethod
def load_tags(cls, tags: Tags) -> BoundaryPaths:
paths = []
assert tags[0].code == 92
grouped_path_tags = group_tags(tags, splitcode=92)
for path_tags in grouped_path_tags:
path_type_flags = path_tags[0].value
is_polyline_path = bool(path_type_flags & 2)
path: AbstractBoundaryPath = (
PolylinePath.load_tags(path_tags)
if is_polyline_path
else EdgePath.load_tags(path_tags)
)
path.path_type_flags = path_type_flags
paths.append(path)
return cls(paths)
@property
def has_edge_paths(self) -> bool:
return any(p.type == BoundaryPathType.EDGE for p in self.paths)
def clear(self) -> None:
"""Remove all boundary paths."""
self.paths = []
def external_paths(self) -> Iterable[AbstractBoundaryPath]:
"""Iterable of external paths, could be empty."""
for b in self.paths:
if b.path_type_flags & const.BOUNDARY_PATH_EXTERNAL:
yield b
def outermost_paths(self) -> Iterable[AbstractBoundaryPath]:
"""Iterable of outermost paths, could be empty."""
for b in self.paths:
if b.path_type_flags & const.BOUNDARY_PATH_OUTERMOST:
yield b
def default_paths(self) -> Iterable[AbstractBoundaryPath]:
"""Iterable of default paths, could be empty."""
not_default = (
const.BOUNDARY_PATH_OUTERMOST + const.BOUNDARY_PATH_EXTERNAL
)
for b in self.paths:
if bool(b.path_type_flags & not_default) is False:
yield b
def rendering_paths(
self, hatch_style: int = const.HATCH_STYLE_NESTED
) -> Iterable[AbstractBoundaryPath]:
"""Iterable of paths to process for rendering, filters unused
boundary paths according to the given hatch style:
- NESTED: use all boundary paths
- OUTERMOST: use EXTERNAL and OUTERMOST boundary paths
- IGNORE: ignore all paths except EXTERNAL boundary paths
Yields paths in order of EXTERNAL, OUTERMOST and DEFAULT.
"""
def path_type_enum(flags) -> int:
if flags & const.BOUNDARY_PATH_EXTERNAL:
return 0
elif flags & const.BOUNDARY_PATH_OUTERMOST:
return 1
return 2
paths = sorted(
(path_type_enum(p.path_type_flags), i, p)
for i, p in enumerate(self.paths)
)
ignore = 1 # EXTERNAL only
if hatch_style == const.HATCH_STYLE_NESTED:
ignore = 3
elif hatch_style == const.HATCH_STYLE_OUTERMOST:
ignore = 2
return (p for path_type, _, p in paths if path_type < ignore)
def add_polyline_path(
self,
path_vertices: Iterable[tuple[float, ...]],
is_closed: bool = True,
flags: int = 1,
) -> PolylinePath:
"""Create and add a new :class:`PolylinePath` object.
Args:
path_vertices: iterable of polyline vertices as (x, y) or
(x, y, bulge)-tuples.
is_closed: 1 for a closed polyline else 0
flags: external(1) or outermost(16) or default (0)
"""
new_path = PolylinePath.from_vertices(path_vertices, is_closed, flags)
self.paths.append(new_path)
return new_path
def add_edge_path(self, flags: int = 1) -> EdgePath:
"""Create and add a new :class:`EdgePath` object.
Args:
flags: external(1) or outermost(16) or default (0)
"""
new_path = EdgePath()
new_path.path_type_flags = flags
self.paths.append(new_path)
return new_path
def export_dxf(self, tagwriter: AbstractTagWriter, dxftype: str) -> None:
tagwriter.write_tag2(91, len(self.paths))
for path in self.paths:
path.export_dxf(tagwriter, dxftype)
def transform(self, ocs: OCSTransform, elevation: float = 0) -> None:
"""Transform HATCH boundary paths.
These paths are 2d elements, placed in the OCS of the HATCH.
"""
if not ocs.scale_uniform:
self.polyline_to_edge_paths(just_with_bulge=True)
self.arc_edges_to_ellipse_edges()
for path in self.paths:
path.transform(ocs, elevation=elevation)
def polyline_to_edge_paths(self, just_with_bulge=True) -> None:
"""Convert polyline paths including bulge values to line- and arc edges.
Args:
just_with_bulge: convert only polyline paths including bulge
values if ``True``
"""
def _edges(points) -> Iterable[Union[LineEdge, ArcEdge]]:
prev_point = None
prev_bulge = None
for x, y, bulge in points:
point = Vec3(x, y)
if prev_point is None:
prev_point = point
prev_bulge = bulge
continue
if prev_bulge != 0:
arc = ArcEdge()
# bulge_to_arc returns always counter-clockwise oriented
# start- and end angles:
(
arc.center,
start_angle,
end_angle,
arc.radius,
) = bulge_to_arc(prev_point, point, prev_bulge)
chk_point = arc.center + Vec2.from_angle(
start_angle, arc.radius
)
arc.ccw = chk_point.isclose(prev_point, abs_tol=1e-9)
arc.start_angle = math.degrees(start_angle) % 360.0
arc.end_angle = math.degrees(end_angle) % 360.0
if math.isclose(
arc.start_angle, arc.end_angle
) and math.isclose(arc.start_angle, 0):
arc.end_angle = 360.0
yield arc
else:
line = LineEdge()
line.start = (prev_point.x, prev_point.y)
line.end = (point.x, point.y)
yield line
prev_point = point
prev_bulge = bulge
def to_edge_path(polyline_path) -> EdgePath:
edge_path = EdgePath()
vertices: list = list(polyline_path.vertices)
if polyline_path.is_closed:
vertices.append(vertices[0])
edge_path.edges = list(_edges(vertices))
return edge_path
for path_index, path in enumerate(self.paths):
if isinstance(path, PolylinePath):
if just_with_bulge and not path.has_bulge():
continue
self.paths[path_index] = to_edge_path(path)
def edge_to_polyline_paths(self, distance: float, segments: int = 16):
"""Convert all edge paths to simple polyline paths without bulges.
Args:
distance: maximum distance from the center of the curve to the
center of the line segment between two approximation points to
determine if a segment should be subdivided.
segments: minimum segment count per curve
"""
converted = []
for path in self.paths:
if path.type == BoundaryPathType.EDGE:
path = flatten_to_polyline_path(path, distance, segments)
converted.append(path)
self.paths = converted
def arc_edges_to_ellipse_edges(self) -> None:
"""Convert all arc edges to ellipse edges."""
def to_ellipse(arc: ArcEdge) -> EllipseEdge:
ellipse = EllipseEdge()
ellipse.center = arc.center
ellipse.ratio = 1.0
ellipse.major_axis = (arc.radius, 0.0)
ellipse.start_angle = arc.start_angle
ellipse.end_angle = arc.end_angle
ellipse.ccw = arc.ccw
return ellipse
for path in self.paths:
if isinstance(path, EdgePath):
edges = path.edges
for edge_index, edge in enumerate(edges):
if isinstance(edge, ArcEdge):
edges[edge_index] = to_ellipse(edge)
def ellipse_edges_to_spline_edges(self, num: int = 32) -> None:
"""
Convert all ellipse edges to spline edges (approximation).
Args:
num: count of control points for a **full** ellipse, partial
ellipses have proportional fewer control points but at least 3.
"""
def to_spline_edge(e: EllipseEdge) -> SplineEdge:
# No OCS transformation needed, source ellipse and target spline
# reside in the same OCS.
ellipse = ConstructionEllipse(
center=e.center,
major_axis=e.major_axis,
ratio=e.ratio,
start_param=e.start_param,
end_param=e.end_param,
)
count = max(int(float(num) * ellipse.param_span / math.tau), 3)
tool = BSpline.ellipse_approximation(ellipse, count)
spline = SplineEdge()
spline.degree = tool.degree
if not e.ccw:
tool = tool.reverse()
spline.control_points = Vec2.list(tool.control_points)
spline.knot_values = tool.knots() # type: ignore
spline.weights = tool.weights() # type: ignore
return spline
for path_index, path in enumerate(self.paths):
if isinstance(path, EdgePath):
edges = path.edges
for edge_index, edge in enumerate(edges):
if isinstance(edge, EllipseEdge):
edges[edge_index] = to_spline_edge(edge)
def spline_edges_to_line_edges(self, factor: int = 8) -> None:
"""Convert all spline edges to line edges (approximation).
Args:
factor: count of approximation segments = count of control
points x factor
"""
def to_line_edges(spline_edge: SplineEdge):
weights = spline_edge.weights
if len(spline_edge.control_points):
bspline = BSpline(
control_points=spline_edge.control_points,
order=spline_edge.degree + 1,
knots=spline_edge.knot_values,
weights=weights if len(weights) else None,
)
elif len(spline_edge.fit_points):
bspline = BSpline.from_fit_points(
spline_edge.fit_points, spline_edge.degree
)
else:
raise const.DXFStructureError(
"SplineEdge() without control points or fit points."
)
segment_count = (max(len(bspline.control_points), 3) - 1) * factor
vertices = list(bspline.approximate(segment_count))
for v1, v2 in zip(vertices[:-1], vertices[1:]):
edge = LineEdge()
edge.start = v1.vec2
edge.end = v2.vec2
yield edge
for path in self.paths:
if isinstance(path, EdgePath):
new_edges = []
for edge in path.edges:
if isinstance(edge, SplineEdge):
new_edges.extend(to_line_edges(edge))
else:
new_edges.append(edge)
path.edges = new_edges
def ellipse_edges_to_line_edges(self, num: int = 64) -> None:
"""Convert all ellipse edges to line edges (approximation).
Args:
num: count of control points for a **full** ellipse, partial
ellipses have proportional fewer control points but at least 3.
"""
def to_line_edges(edge):
# Start- and end params are always stored in counter clockwise order!
ellipse = ConstructionEllipse(
center=edge.center,
major_axis=edge.major_axis,
ratio=edge.ratio,
start_param=edge.start_param,
end_param=edge.end_param,
)
segment_count = max(
int(float(num) * ellipse.param_span / math.tau), 3
)
params = ellipse.params(segment_count + 1)
# Reverse path if necessary!
if not edge.ccw:
params = reversed(list(params))
vertices = list(ellipse.vertices(params))
for v1, v2 in zip(vertices[:-1], vertices[1:]):
line = LineEdge()
line.start = v1.vec2
line.end = v2.vec2
yield line
for path in self.paths:
if isinstance(path, EdgePath):
new_edges = []
for edge in path.edges:
if isinstance(edge, EllipseEdge):
new_edges.extend(to_line_edges(edge))
else:
new_edges.append(edge)
path.edges = new_edges
def all_to_spline_edges(self, num: int = 64) -> None:
"""Convert all bulge, arc and ellipse edges to spline edges
(approximation).
Args:
num: count of control points for a **full** circle/ellipse, partial
circles/ellipses have proportional fewer control points but at
least 3.
"""
self.polyline_to_edge_paths(just_with_bulge=True)
self.arc_edges_to_ellipse_edges()
self.ellipse_edges_to_spline_edges(num)
def all_to_line_edges(self, num: int = 64, spline_factor: int = 8) -> None:
"""Convert all bulge, arc and ellipse edges to spline edges and
approximate this splines by line edges.
Args:
num: count of control points for a **full** circle/ellipse, partial
circles/ellipses have proportional fewer control points but at
least 3.
spline_factor: count of spline approximation segments = count of
control points x spline_factor
"""
self.polyline_to_edge_paths(just_with_bulge=True)
self.arc_edges_to_ellipse_edges()
self.ellipse_edges_to_line_edges(num)
self.spline_edges_to_line_edges(spline_factor)
def has_critical_elements(self) -> bool:
"""Returns ``True`` if any boundary path has bulge values or arc edges
or ellipse edges.
"""
for path in self.paths:
if isinstance(path, PolylinePath):
return path.has_bulge()
elif isinstance(path, EdgePath):
for edge in path.edges:
if edge.type in {EdgeType.ARC, EdgeType.ELLIPSE}:
return True
else:
raise TypeError(type(path))
return False
def flatten_to_polyline_path(
path: AbstractBoundaryPath, distance: float, segments: int = 16
) -> PolylinePath:
import ezdxf.path # avoid cyclic imports
# keep path in original OCS!
ez_path = ezdxf.path.from_hatch_boundary_path(path, ocs=OCS(), elevation=0)
vertices = ((v.x, v.y) for v in ez_path.flattening(distance, segments))
return PolylinePath.from_vertices(
vertices,
flags=path.path_type_flags,
)
def pop_source_boundary_objects_tags(path_tags: Tags) -> list[str]:
source_boundary_object_tags = []
while len(path_tags):
if path_tags[-1].code in (97, 330):
last_tag = path_tags.pop()
if last_tag.code == 330:
source_boundary_object_tags.append(last_tag.value)
else: # code == 97
# result list does not contain the length tag!
source_boundary_object_tags.reverse()
return source_boundary_object_tags
else:
break
# No source boundary objects found - entity is not valid for AutoCAD
return []
def export_source_boundary_objects(
tagwriter: AbstractTagWriter, handles: Sequence[str]
):
tagwriter.write_tag2(97, len(handles))
for handle in handles:
tagwriter.write_tag2(330, handle)
class PolylinePath(AbstractBoundaryPath):
type = BoundaryPathType.POLYLINE
def __init__(self) -> None:
self.path_type_flags: int = const.BOUNDARY_PATH_POLYLINE
self.is_closed = False
# list of 2D coordinates with bulge values (x, y, bulge);
# bulge default = 0.0
self.vertices: list[tuple[float, float, float]] = []
# MPOLYGON does not support source boundary objects, the MPOLYGON is
# the source object!
self.source_boundary_objects: list[str] = [] # (330, handle) tags
def is_valid(self) -> bool:
return True
@classmethod
def from_vertices(
cls,
path_vertices: Iterable[tuple[float, ...]],
is_closed: bool = True,
flags: int = 1,
) -> PolylinePath:
"""Create a new :class:`PolylinePath` object from vertices.
Args:
path_vertices: iterable of polyline vertices as (x, y) or
(x, y, bulge)-tuples.
is_closed: 1 for a closed polyline else 0
flags: external(1) or outermost(16) or default (0)
"""
new_path = PolylinePath()
new_path.set_vertices(path_vertices, is_closed)
new_path.path_type_flags = flags | const.BOUNDARY_PATH_POLYLINE
return new_path
@classmethod
def load_tags(cls, tags: Tags) -> PolylinePath:
path = PolylinePath()
path.source_boundary_objects = pop_source_boundary_objects_tags(tags)
for tag in tags:
code, value = tag
if code == 10: # vertex coordinates
# (x, y, bulge); bulge default = 0.0
path.vertices.append((value[0], value[1], 0.0))
elif code == 42: # bulge value
x, y, bulge = path.vertices.pop()
# Last coordinates with new bulge value
path.vertices.append((x, y, value))
elif code == 72:
pass # ignore this value
elif code == 73:
path.is_closed = value
elif code == 92:
path.path_type_flags = value
elif code == 93: # number of polyline vertices
pass # ignore this value
return path
def set_vertices(
self, vertices: Iterable[Sequence[float]], is_closed: bool = True
) -> None:
"""Set new `vertices` as new polyline path, a vertex has to be a
(x, y) or a (x, y, bulge)-tuple.
"""
new_vertices = []
for vertex in vertices:
if len(vertex) == 2:
x, y = vertex
bulge = 0.0
elif len(vertex) == 3:
x, y, bulge = vertex
else:
raise const.DXFValueError(
"Invalid vertex format, expected (x, y) or (x, y, bulge)"
)
new_vertices.append((x, y, bulge))
self.vertices = new_vertices
self.is_closed = is_closed
def clear(self) -> None:
"""Removes all vertices and all handles to associated DXF objects
(:attr:`source_boundary_objects`).
"""
self.vertices = []
self.is_closed = False
self.source_boundary_objects = []
def has_bulge(self) -> bool:
return any(bulge for x, y, bulge in self.vertices)
def export_dxf(self, tagwriter: AbstractTagWriter, dxftype: str) -> None:
has_bulge = self.has_bulge()
write_tag = tagwriter.write_tag2
write_tag(92, int(self.path_type_flags))
if dxftype == "HATCH": # great design :<
write_tag(72, int(has_bulge))
write_tag(73, int(self.is_closed))
elif dxftype == "MPOLYGON":
write_tag(73, int(self.is_closed))
write_tag(72, int(has_bulge))
else:
raise ValueError(f"unsupported DXF type {dxftype}")
write_tag(93, len(self.vertices))
for x, y, bulge in self.vertices:
tagwriter.write_vertex(10, (float(x), float(y)))
if has_bulge:
write_tag(42, float(bulge))
if dxftype == "HATCH":
export_source_boundary_objects(
tagwriter, self.source_boundary_objects
)
def transform(self, ocs: OCSTransform, elevation: float) -> None:
"""Transform polyline path."""
has_non_uniform_scaling = not ocs.scale_uniform
def _transform():
for x, y, bulge in self.vertices:
# PolylinePaths() with arcs should be converted to
# EdgePath(in BoundaryPath.transform()).
if bulge and has_non_uniform_scaling:
raise NonUniformScalingError(
"Polyline path with arcs does not support non-uniform "
"scaling"
)
v = ocs.transform_vertex(Vec3(x, y, elevation))
yield v.x, v.y, bulge
if self.vertices:
self.vertices = list(_transform())
class EdgePath(AbstractBoundaryPath):
type = BoundaryPathType.EDGE
def __init__(self) -> None:
self.path_type_flags = const.BOUNDARY_PATH_DEFAULT
self.edges: list[AbstractEdge] = []
self.source_boundary_objects = []
def __iter__(self):
return iter(self.edges)
def is_valid(self) -> bool:
return all(e.is_valid() for e in self.edges)
@classmethod
def load_tags(cls, tags: Tags) -> EdgePath:
edge_path = cls()
edge_path.source_boundary_objects = pop_source_boundary_objects_tags(
tags
)
for edge_tags in group_tags(tags, splitcode=72):
if len(edge_tags) == 0:
continue
edge_type = edge_tags[0].value
if 0 < edge_type < 5:
edge_path.edges.append(EDGE_CLASSES[edge_type].load_tags(edge_tags[1:]))
return edge_path
def transform(self, ocs: OCSTransform, elevation: float) -> None:
"""Transform edge boundary paths."""
for edge in self.edges:
edge.transform(ocs, elevation=elevation)
def add_line(self, start: UVec, end: UVec) -> LineEdge:
"""Add a :class:`LineEdge` from `start` to `end`.
Args:
start: start point of line, (x, y)-tuple
end: end point of line, (x, y)-tuple
"""
line = LineEdge()
line.start = Vec2(start)
line.end = Vec2(end)
self.edges.append(line)
return line
def add_arc(
self,
center: UVec,
radius: float = 1.0,
start_angle: float = 0.0,
end_angle: float = 360.0,
ccw: bool = True,
) -> ArcEdge:
"""Add an :class:`ArcEdge`.
**Adding Clockwise Oriented Arcs:**
Clockwise oriented :class:`ArcEdge` objects are sometimes necessary to
build closed loops, but the :class:`ArcEdge` objects are always
represented in counter-clockwise orientation.
To add a clockwise oriented :class:`ArcEdge` you have to swap the
start- and end angle and set the `ccw` flag to ``False``,
e.g. to add a clockwise oriented :class:`ArcEdge` from 180 to 90 degree,
add the :class:`ArcEdge` in counter-clockwise orientation with swapped
angles::
edge_path.add_arc(center, radius, start_angle=90, end_angle=180, ccw=False)
Args:
center: center point of arc, (x, y)-tuple
radius: radius of circle
start_angle: start angle of arc in degrees (`end_angle` for a
clockwise oriented arc)
end_angle: end angle of arc in degrees (`start_angle` for a
clockwise oriented arc)
ccw: ``True`` for counter-clockwise ``False`` for
clockwise orientation
"""
arc = ArcEdge()
arc.center = Vec2(center)
arc.radius = radius
# Start- and end angles always for counter-clockwise oriented arcs!
arc.start_angle = start_angle
arc.end_angle = end_angle
# Flag to export the counter-clockwise oriented arc in
# correct clockwise orientation:
arc.ccw = bool(ccw)
self.edges.append(arc)
return arc
def add_ellipse(
self,
center: UVec,
major_axis: UVec = (1.0, 0.0),
ratio: float = 1.0,
start_angle: float = 0.0,
end_angle: float = 360.0,
ccw: bool = True,
) -> EllipseEdge:
"""Add an :class:`EllipseEdge`.
**Adding Clockwise Oriented Ellipses:**
Clockwise oriented :class:`EllipseEdge` objects are sometimes necessary
to build closed loops, but the :class:`EllipseEdge` objects are always
represented in counter-clockwise orientation.
To add a clockwise oriented :class:`EllipseEdge` you have to swap the
start- and end angle and set the `ccw` flag to ``False``,
e.g. to add a clockwise oriented :class:`EllipseEdge` from 180 to 90
degree, add the :class:`EllipseEdge` in counter-clockwise orientation
with swapped angles::
edge_path.add_ellipse(center, major_axis, ratio, start_angle=90, end_angle=180, ccw=False)
Args:
center: center point of ellipse, (x, y)-tuple
major_axis: vector of major axis as (x, y)-tuple
ratio: ratio of minor axis to major axis as float
start_angle: start angle of ellipse in degrees (`end_angle` for a
clockwise oriented ellipse)
end_angle: end angle of ellipse in degrees (`start_angle` for a
clockwise oriented ellipse)
ccw: ``True`` for counter-clockwise ``False`` for
clockwise orientation
"""
if ratio > 1.0:
raise const.DXFValueError("argument 'ratio' has to be <= 1.0")
ellipse = EllipseEdge()
ellipse.center = Vec2(center)
ellipse.major_axis = Vec2(major_axis)
ellipse.ratio = ratio
# Start- and end angles are always stored in counter-clockwise
# orientation!
ellipse.start_angle = start_angle
ellipse.end_angle = end_angle
# Flag to export the counter-clockwise oriented ellipse in
# correct clockwise orientation:
ellipse.ccw = bool(ccw)
self.edges.append(ellipse)
return ellipse
def add_spline(
self,
fit_points: Optional[Iterable[UVec]] = None,
control_points: Optional[Iterable[UVec]] = None,
knot_values: Optional[Iterable[float]] = None,
weights: Optional[Iterable[float]] = None,
degree: int = 3,
periodic: int = 0,
start_tangent: Optional[UVec] = None,
end_tangent: Optional[UVec] = None,
) -> SplineEdge:
"""Add a :class:`SplineEdge`.
Args:
fit_points: points through which the spline must go, at least 3 fit
points are required. list of (x, y)-tuples
control_points: affects the shape of the spline, mandatory and
AutoCAD crashes on invalid data. list of (x, y)-tuples
knot_values: (knot vector) mandatory and AutoCAD crashes on invalid
data. list of floats; `ezdxf` provides two tool functions to
calculate valid knot values: :func:`ezdxf.math.uniform_knot_vector`,
:func:`ezdxf.math.open_uniform_knot_vector` (default if ``None``)
weights: weight of control point, not mandatory, list of floats.
degree: degree of spline (int)
periodic: 1 for periodic spline, 0 for none periodic spline
start_tangent: start_tangent as 2d vector, optional
end_tangent: end_tangent as 2d vector, optional
.. warning::
Unlike for the spline entity AutoCAD does not calculate the
necessary `knot_values` for the spline edge itself. On the contrary,
if the `knot_values` in the spline edge are missing or invalid
AutoCAD **crashes**.
"""
spline = SplineEdge()
if fit_points is not None:
spline.fit_points = Vec2.list(fit_points)
if control_points is not None:
spline.control_points = Vec2.list(control_points)
if knot_values is not None:
spline.knot_values = list(knot_values)
else:
spline.knot_values = list(
open_uniform_knot_vector(len(spline.control_points), degree + 1)
)
if weights is not None:
spline.weights = list(weights)
spline.degree = degree
spline.rational = int(bool(len(spline.weights)))
spline.periodic = int(periodic)
if start_tangent is not None:
spline.start_tangent = Vec2(start_tangent)
if end_tangent is not None:
spline.end_tangent = Vec2(end_tangent)
self.edges.append(spline)
return spline
def add_spline_control_frame(
self,
fit_points: Iterable[tuple[float, float]],
degree: int = 3,
method: str = "distance",
) -> SplineEdge:
bspline = global_bspline_interpolation(
fit_points=fit_points, degree=degree, method=method
)
return self.add_spline(
fit_points=fit_points,
control_points=bspline.control_points,
knot_values=bspline.knots(),
)
def clear(self) -> None:
"""Delete all edges."""
self.edges = []
def export_dxf(self, tagwriter: AbstractTagWriter, dxftype: str) -> None:
tagwriter.write_tag2(92, int(self.path_type_flags))
tagwriter.write_tag2(93, len(self.edges))
for edge in self.edges:
edge.export_dxf(tagwriter)
export_source_boundary_objects(tagwriter, self.source_boundary_objects)
class LineEdge(AbstractEdge):
EDGE_TYPE = "LineEdge" # 2021-05-31: deprecated use type
type = EdgeType.LINE
def __init__(self):
self.start = Vec2(0, 0) # OCS!
self.end = Vec2(0, 0) # OCS!
def is_valid(self) -> bool:
return True
@property
def start_point(self) -> Vec2:
return self.start
@property
def end_point(self) -> Vec2:
return self.end
@classmethod
def load_tags(cls, tags: Tags) -> LineEdge:
edge = cls()
for tag in tags:
code, value = tag
if code == 10:
edge.start = Vec2(value)
elif code == 11:
edge.end = Vec2(value)
return edge
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_tag2(72, 1) # edge type
x, y, *_ = self.start
tagwriter.write_tag2(10, float(x))
tagwriter.write_tag2(20, float(y))
x, y, *_ = self.end
tagwriter.write_tag2(11, float(x))
tagwriter.write_tag2(21, float(y))
def transform(self, ocs: OCSTransform, elevation: float) -> None:
self.start = ocs.transform_2d_vertex(self.start, elevation)
self.end = ocs.transform_2d_vertex(self.end, elevation)
class ArcEdge(AbstractEdge):
type = EdgeType.ARC # 2021-05-31: deprecated use type
EDGE_TYPE = "ArcEdge"
def __init__(self) -> None:
self.center = Vec2(0.0, 0.0)
self.radius: float = 1.0
# Start- and end angles are always stored in counter-clockwise order!
self.start_angle: float = 0.0
self.end_angle: float = 360.0
# Flag to preserve the required orientation for DXF export:
self.ccw: bool = True
def is_valid(self) -> bool:
return True
@property
def start_point(self) -> Vec2:
return self.construction_tool().start_point
@property
def end_point(self) -> Vec2:
return self.construction_tool().end_point
@classmethod
def load_tags(cls, tags: Tags) -> ArcEdge:
edge = cls()
start = 0.0
end = 0.0
for tag in tags:
code, value = tag
if code == 10:
edge.center = Vec2(value)
elif code == 40:
edge.radius = value
elif code == 50:
start = value
elif code == 51:
end = value
elif code == 73:
edge.ccw = bool(value)
# The DXF format stores the clockwise oriented start- and end angles
# for HATCH arc- and ellipse edges as complementary angle (360-angle).
# This is a problem in many ways for processing clockwise oriented
# angles correct, especially rotation transformation won't work.
# Solution: convert clockwise angles into counter-clockwise angles
# and swap start- and end angle at loading and exporting, the ccw flag
# preserves the required orientation of the arc:
if edge.ccw:
edge.start_angle = start
edge.end_angle = end
else:
edge.start_angle = 360.0 - end
edge.end_angle = 360.0 - start
return edge
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_tag2(72, 2) # edge type
x, y, *_ = self.center
if self.ccw:
start = self.start_angle
end = self.end_angle
else:
# swap and convert to complementary angles: see ArcEdge.load_tags()
# for explanation
start = 360.0 - self.end_angle
end = 360.0 - self.start_angle
tagwriter.write_tag2(10, float(x))
tagwriter.write_tag2(20, float(y))
tagwriter.write_tag2(40, self.radius)
tagwriter.write_tag2(50, start)
tagwriter.write_tag2(51, end)
tagwriter.write_tag2(73, int(self.ccw))
def transform(self, ocs: OCSTransform, elevation: float) -> None:
self.center = ocs.transform_2d_vertex(self.center, elevation)
self.radius = ocs.transform_length(Vec3(self.radius, 0, 0))
if not math.isclose(
arc_angle_span_deg(self.start_angle, self.end_angle), 360.0
): # open arc
# The transformation of the ccw flag is not necessary for the current
# implementation of OCS transformations. The arc angles have always
# a counter clockwise orientation around the extrusion vector and
# this orientation is preserved even for mirroring, which flips the
# extrusion vector to (0, 0, -1) for entities in the xy-plane.
self.start_angle = ocs.transform_deg_angle(self.start_angle)
self.end_angle = ocs.transform_deg_angle(self.end_angle)
else: # full circle
# Transform only start point to preserve the connection point to
# adjacent edges:
self.start_angle = ocs.transform_deg_angle(self.start_angle)
# ArcEdge is represented in counter-clockwise orientation:
self.end_angle = self.start_angle + 360.0
def construction_tool(self) -> ConstructionArc:
"""Returns ConstructionArc() for the OCS representation."""
return ConstructionArc(
center=self.center,
radius=self.radius,
start_angle=self.start_angle,
end_angle=self.end_angle,
)
class EllipseEdge(AbstractEdge):
EDGE_TYPE = "EllipseEdge" # 2021-05-31: deprecated use type
type = EdgeType.ELLIPSE
def __init__(self) -> None:
self.center = Vec2((0.0, 0.0))
# Endpoint of major axis relative to center point (in OCS)
self.major_axis = Vec2((1.0, 0.0))
self.ratio: float = 1.0
# Start- and end angles are always stored in counter-clockwise order!
self.start_angle: float = 0.0 # start param, not a real angle
self.end_angle: float = 360.0 # end param, not a real angle
# Flag to preserve the required orientation for DXF export:
self.ccw: bool = True
def is_valid(self) -> bool:
return True
@property
def start_point(self) -> Vec2:
return self.construction_tool().start_point.vec2
@property
def end_point(self) -> Vec2:
return self.construction_tool().end_point.vec2
@property
def start_param(self) -> float:
return angle_to_param(self.ratio, math.radians(self.start_angle))
@start_param.setter
def start_param(self, param: float) -> None:
self.start_angle = math.degrees(param_to_angle(self.ratio, param))
@property
def end_param(self) -> float:
return angle_to_param(self.ratio, math.radians(self.end_angle))
@end_param.setter
def end_param(self, param: float) -> None:
self.end_angle = math.degrees(param_to_angle(self.ratio, param))
@classmethod
def load_tags(cls, tags: Tags) -> EllipseEdge:
edge = cls()
start = 0.0
end = 0.0
for tag in tags:
code, value = tag
if code == 10:
edge.center = Vec2(value)
elif code == 11:
edge.major_axis = Vec2(value)
elif code == 40:
edge.ratio = value
elif code == 50:
start = value
elif code == 51:
end = value
elif code == 73:
edge.ccw = bool(value)
if edge.ccw:
edge.start_angle = start
edge.end_angle = end
else:
# The DXF format stores the clockwise oriented start- and end angles
# for HATCH arc- and ellipse edges as complementary angle (360-angle).
# This is a problem in many ways for processing clockwise oriented
# angles correct, especially rotation transformation won't work.
# Solution: convert clockwise angles into counter-clockwise angles
# and swap start- and end angle at loading and exporting, the ccw flag
# preserves the required orientation of the ellipse:
edge.start_angle = 360.0 - end
edge.end_angle = 360.0 - start
return edge
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
tagwriter.write_tag2(72, 3) # edge type
x, y, *_ = self.center
tagwriter.write_tag2(10, float(x))
tagwriter.write_tag2(20, float(y))
x, y, *_ = self.major_axis
tagwriter.write_tag2(11, float(x))
tagwriter.write_tag2(21, float(y))
tagwriter.write_tag2(40, self.ratio)
if self.ccw:
start = self.start_angle
end = self.end_angle
else:
# swap and convert to complementary angles: see EllipseEdge.load_tags()
# for explanation
start = 360.0 - self.end_angle
end = 360.0 - self.start_angle
tagwriter.write_tag2(50, start)
tagwriter.write_tag2(51, end)
tagwriter.write_tag2(73, int(self.ccw))
def construction_tool(self) -> ConstructionEllipse:
"""Returns ConstructionEllipse() for the OCS representation."""
return ConstructionEllipse(
center=Vec3(self.center),
major_axis=Vec3(self.major_axis),
extrusion=Vec3(0, 0, 1),
ratio=self.ratio,
# 1. ConstructionEllipse() is always in ccw orientation
# 2. Start- and end params are always stored in ccw orientation
start_param=self.start_param,
end_param=self.end_param,
)
def transform(self, ocs: OCSTransform, elevation: float) -> None:
e = self.construction_tool()
# Transform old OCS representation to WCS
ocs_to_wcs = ocs.old_ocs.to_wcs
e.center = ocs_to_wcs(e.center.replace(z=elevation))
e.major_axis = ocs_to_wcs(e.major_axis)
e.extrusion = ocs.old_extrusion
# Apply matrix transformation
e.transform(ocs.m)
# Transform WCS representation to new OCS
wcs_to_ocs = ocs.new_ocs.from_wcs
self.center = wcs_to_ocs(e.center).vec2
self.major_axis = wcs_to_ocs(e.major_axis).vec2
self.ratio = e.ratio
# ConstructionEllipse() is always in ccw orientation
# Start- and end params are always stored in ccw orientation
self.start_param = e.start_param
self.end_param = e.end_param
# The transformation of the ccw flag is not necessary for the current
# implementation of OCS transformations.
# An ellipse as boundary edge is an OCS entity!
# The ellipse angles have always a counter clockwise orientation around
# the extrusion vector and this orientation is preserved even for
# mirroring, which flips the extrusion vector to (0, 0, -1) for
# entities in the xy-plane.
# normalize angles in range 0 to 360 degrees
self.start_angle = self.start_angle % 360.0
self.end_angle = self.end_angle % 360.0
if math.isclose(self.end_angle, 0):
self.end_angle = 360.0
class SplineEdge(AbstractEdge):
EDGE_TYPE = "SplineEdge" # 2021-05-31: deprecated use type
type = EdgeType.SPLINE
def __init__(self) -> None:
self.degree: int = 3 # code = 94
self.rational: int = 0 # code = 73
self.periodic: int = 0 # code = 74
self.knot_values: list[float] = []
self.control_points: list[Vec2] = []
self.fit_points: list[Vec2] = []
self.weights: list[float] = []
# do not set tangents by default to (0, 0)
self.start_tangent: Optional[Vec2] = None
self.end_tangent: Optional[Vec2] = None
def is_valid(self) -> bool:
if len(self.control_points):
order = self.degree + 1
count = len(self.control_points)
if order > count:
return False
required_knot_count = count + order
if len(self.knot_values) != required_knot_count:
return False
elif len(self.fit_points) < 2:
return False
return True
@property
def start_point(self) -> Vec2:
return self.control_points[0]
@property
def end_point(self) -> Vec2:
return self.control_points[-1]
@classmethod
def load_tags(cls, tags: Tags) -> SplineEdge:
edge = cls()
for tag in tags:
code, value = tag
if code == 94:
edge.degree = value
elif code == 73:
edge.rational = value
elif code == 74:
edge.periodic = value
elif code == 40:
edge.knot_values.append(value)
elif code == 42:
edge.weights.append(value)
elif code == 10:
edge.control_points.append(Vec2(value))
elif code == 11:
edge.fit_points.append(Vec2(value))
elif code == 12:
edge.start_tangent = Vec2(value)
elif code == 13:
edge.end_tangent = Vec2(value)
return edge
def export_dxf(self, tagwriter: AbstractTagWriter) -> None:
def set_required_tangents(points: list[Vec2]):
if len(points) > 1:
if self.start_tangent is None:
self.start_tangent = points[1] - points[0]
if self.end_tangent is None:
self.end_tangent = points[-1] - points[-2]
if len(self.weights):
if len(self.weights) == len(self.control_points):
self.rational = 1
else:
raise const.DXFValueError(
"SplineEdge: count of control points and count of weights "
"mismatch"
)
else:
self.rational = 0
write_tag = tagwriter.write_tag2
write_tag(72, 4) # edge type
write_tag(94, int(self.degree))
write_tag(73, int(self.rational))
write_tag(74, int(self.periodic))
write_tag(95, len(self.knot_values)) # number of knots
write_tag(96, len(self.control_points)) # number of control points
# build knot values list
# knot values have to be present and valid, otherwise AutoCAD crashes
if len(self.knot_values):
for value in self.knot_values:
write_tag(40, float(value))
else:
raise const.DXFValueError(
"SplineEdge: missing required knot values"
)
# build control points
# control points have to be present and valid, otherwise AutoCAD crashes
cp = Vec2.list(self.control_points)
if self.rational:
for point, weight in zip(cp, self.weights):
write_tag(10, float(point.x))
write_tag(20, float(point.y))
write_tag(42, float(weight))
else:
for x, y in cp:
write_tag(10, float(x))
write_tag(20, float(y))
# build optional fit points
if len(self.fit_points) > 0:
set_required_tangents(cp)
write_tag(97, len(self.fit_points))
for x, y, *_ in self.fit_points:
write_tag(11, float(x))
write_tag(21, float(y))
elif tagwriter.dxfversion >= const.DXF2010:
# (97, 0) len tag required by AutoCAD 2010+
write_tag(97, 0)
if self.start_tangent is not None:
x, y, *_ = self.start_tangent
write_tag(12, float(x))
write_tag(22, float(y))
if self.end_tangent is not None:
x, y, *_ = self.end_tangent
write_tag(13, float(x))
write_tag(23, float(y))
def transform(self, ocs: OCSTransform, elevation: float) -> None:
self.control_points = list(
ocs.transform_2d_vertex(v, elevation) for v in self.control_points
)
self.fit_points = list(
ocs.transform_2d_vertex(v, elevation) for v in self.fit_points
)
if self.start_tangent is not None:
t = Vec3(self.start_tangent).replace(z=elevation)
self.start_tangent = ocs.transform_direction(t).vec2
if self.end_tangent is not None:
t = Vec3(self.end_tangent).replace(z=elevation)
self.end_tangent = ocs.transform_direction(t).vec2
def construction_tool(self) -> BSpline:
"""Returns BSpline() for the OCS representation."""
return BSpline(
control_points=self.control_points,
knots=self.knot_values,
order=self.degree + 1,
weights=self.weights,
)
EDGE_CLASSES = [None, LineEdge, ArcEdge, EllipseEdge, SplineEdge]