# 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]