# Copyright (c) 2019-2024 Manfred Moitzi # License: MIT License from __future__ import annotations from typing import ( TYPE_CHECKING, Tuple, Sequence, Iterable, Union, Iterator, Optional, ) from typing_extensions import TypeAlias, Self import array import copy from contextlib import contextmanager from ezdxf.math import Vec3, Matrix44, Z_AXIS from ezdxf.math.transformtools import OCSTransform, NonUniformScalingError from ezdxf.lldxf import validator from ezdxf.lldxf.attributes import ( DXFAttr, DXFAttributes, DefSubclass, XType, RETURN_DEFAULT, group_code_mapping, ) from ezdxf.lldxf.const import ( SUBCLASS_MARKER, DXF2000, LWPOLYLINE_CLOSED, DXFStructureError, ) from ezdxf.lldxf.tags import Tags from ezdxf.lldxf.types import DXFTag, DXFVertex from ezdxf.lldxf.packedtags import VertexArray from ezdxf.render.polyline import virtual_lwpolyline_entities from ezdxf.explode import explode_entity from ezdxf.query import EntityQuery from .dxfentity import base_class, SubclassProcessor from .dxfgfx import DXFGraphic, acdb_entity from .factory import register_entity from .copy import default_copy if TYPE_CHECKING: from ezdxf.entities import DXFNamespace, Line, Arc, DXFEntity from ezdxf.lldxf.tagwriter import AbstractTagWriter from ezdxf.layouts import BaseLayout __all__ = ["LWPolyline", "FORMAT_CODES"] LWPointType: TypeAlias = Tuple[float, float, float, float, float] FORMAT_CODES = frozenset("xysebv") DEFAULT_FORMAT = "xyseb" LWPOINTCODES = (10, 20, 40, 41, 42) # Order does matter: # If tag 90 is not the first TAG, AutoCAD does not close the polyline, when the # `close` flag is set. acdb_lwpolyline = DefSubclass( "AcDbPolyline", { # Count always returns the actual length: "count": DXFAttr(90, xtype=XType.callback, getter="__len__"), # Elevation: OCS z-axis value for all vertices: "elevation": DXFAttr(38, default=0, optional=True), # Thickness can be negative! "thickness": DXFAttr(39, default=0, optional=True), # Flags: # 1 = Closed # 128 = Plinegen "flags": DXFAttr(70, default=0), # Const width: DXF reference error - AutoCAD uses just const width if not 0, # for all line segments. "const_width": DXFAttr(43, default=0, optional=True), "extrusion": DXFAttr( 210, xtype=XType.point3d, default=Z_AXIS, optional=True, validator=validator.is_not_null_vector, fixer=RETURN_DEFAULT, ), # 10, 20 : Vertex x, y # 91: vertex identifier ??? # 40, 41, 42: start width, end width, bulge }, ) acdb_lwpolyline_group_codes = group_code_mapping(acdb_lwpolyline) @register_entity class LWPolyline(DXFGraphic): """DXF LWPOLYLINE entity""" DXFTYPE = "LWPOLYLINE" DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_lwpolyline) MIN_DXF_VERSION_FOR_EXPORT = DXF2000 def __init__(self): super().__init__() self.lwpoints = LWPolylinePoints() def copy_data(self, entity: Self, copy_strategy=default_copy) -> None: """Copy lwpoints.""" assert isinstance(entity, LWPolyline) entity.lwpoints = copy.deepcopy(self.lwpoints) def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: """ Adds subclass processing for AcDbPolyline, requires previous base class and AcDbEntity processing by parent class. """ dxf = super().load_dxf_attribs(processor) if processor: tags = processor.subclass_by_index(2) if tags: tags = self.load_vertices(tags) processor.fast_load_dxfattribs( dxf, acdb_lwpolyline_group_codes, subclass=tags, recover=True, ) else: raise DXFStructureError( f"missing 'AcDbPolyline' subclass in LWPOLYLINE(#{dxf.handle})" ) return dxf def load_vertices(self, tags: Tags) -> Tags: self.lwpoints, unprocessed_tags = LWPolylinePoints.from_tags(tags) return unprocessed_tags def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool: # Returns True if entity should be exported # Do not export polylines without vertices return len(self.lwpoints) > 0 def export_entity(self, tagwriter: AbstractTagWriter) -> None: """Export entity specific data as DXF tags.""" super().export_entity(tagwriter) tagwriter.write_tag2(SUBCLASS_MARKER, acdb_lwpolyline.name) self.dxf.export_dxf_attribs( tagwriter, ["count", "flags", "const_width", "elevation", "thickness"], ) tagwriter.write_tags(Tags(self.lwpoints.dxftags())) self.dxf.export_dxf_attribs(tagwriter, "extrusion") @property def closed(self) -> bool: """Get/set closed state of polyline. A closed polyline has a connection segment from the last vertex to the first vertex. """ return self.get_flag_state(LWPOLYLINE_CLOSED) @closed.setter def closed(self, status: bool) -> None: self.set_flag_state(LWPOLYLINE_CLOSED, status) @property def is_closed(self) -> bool: """Get closed state of LWPOLYLINE. Compatibility interface to :class:`Polyline` """ return self.get_flag_state(LWPOLYLINE_CLOSED) def close(self, state: bool = True) -> None: """Set closed state of LWPOLYLINE. Compatibility interface to :class:`Polyline` """ self.closed = state @property def has_arc(self) -> bool: """Returns ``True`` if LWPOLYLINE has an arc segment.""" return any(bool(b) for x, y, s, e, b in self.lwpoints) @property def has_width(self) -> bool: """Returns ``True`` if LWPOLYLINE has any segment with width attributes or the DXF attribute const_width is not 0. """ if self.dxf.hasattr("const_width"): # 'const_width' overrides all individual start- or end width settings. # The DXF reference claims the opposite, but that is simply not true. return self.dxf.const_width != 0.0 return any((s or e) for x, y, s, e, b in self.lwpoints) def __len__(self) -> int: """Returns count of polyline points.""" return len(self.lwpoints) def __iter__(self) -> Iterator[LWPointType]: """Returns iterable of tuples (x, y, start_width, end_width, bulge).""" return iter(self.lwpoints) def __getitem__(self, index: int) -> LWPointType: """Returns point at position `index` as (x, y, start_width, end_width, bulge) tuple. start_width, end_width and bulge is 0 if not present, supports extended slicing. Point format is fixed as "xyseb". All coordinates in :ref:`OCS`. """ return self.lwpoints[index] def __setitem__(self, index: int, value: Sequence[float]) -> None: """ Set point at position `index` as (x, y, [start_width, [end_width, [bulge]]]) tuple. If start_width or end_width is 0 or left off the default width value is used. If the bulge value is left off, bulge is 0 by default (straight line). Does NOT support extend slicing. Point format is fixed as "xyseb". All coordinates in :ref:`OCS`. Args: index: point index value: point value as (x, y, [start_width, [end_width, [bulge]]]) tuple """ self.lwpoints[index] = compile_array(value) def __delitem__(self, index: int) -> None: """Delete point at position `index`, supports extended slicing.""" del self.lwpoints[index] def vertices(self) -> Iterator[tuple[float, float]]: """ Returns iterable of all polyline points as (x, y) tuples in :ref:`OCS` (:attr:`dxf.elevation` is the z-axis value). """ for point in self: yield point[0], point[1] def vertices_in_wcs(self) -> Iterator[Vec3]: """Returns iterable of all polyline points as Vec3(x, y, z) in :ref:`WCS`.""" ocs = self.ocs() elevation = self.get_dxf_attrib("elevation", default=0.0) for x, y in self.vertices(): yield ocs.to_wcs(Vec3(x, y, elevation)) def vertices_in_ocs(self) -> Iterator[Vec3]: """Returns iterable of all polyline points as Vec3(x, y, z) in :ref:`OCS`.""" elevation = self.get_dxf_attrib("elevation", default=0.0) for x, y in self.vertices(): yield Vec3(x, y, elevation) def append(self, point: Sequence[float], format: str = DEFAULT_FORMAT) -> None: """Append `point` to polyline, `format` specifies a user defined point format. All coordinates in :ref:`OCS`. Args: point: (x, y, [start_width, [end_width, [bulge]]]) tuple format: format string, default is "xyseb", see: `format codes`_ """ self.lwpoints.append(point, format=format) def insert( self, pos: int, point: Sequence[float], format: str = DEFAULT_FORMAT ) -> None: """Insert new point in front of positions `pos`, `format` specifies a user defined point format. All coordinates in :ref:`OCS`. Args: pos: insert position point: point data format: format string, default is "xyseb", see: `format codes`_ """ data = compile_array(point, format=format) self.lwpoints.insert(pos, data) def append_points( self, points: Iterable[Sequence[float]], format: str = DEFAULT_FORMAT ) -> None: """ Append new `points` to polyline, `format` specifies a user defined point format. All coordinates in :ref:`OCS`. Args: points: iterable of point, point is (x, y, [start_width, [end_width, [bulge]]]) tuple format: format string, default is "xyseb", see: `format codes`_ """ for point in points: self.lwpoints.append(point, format=format) @contextmanager def points(self, format: str = DEFAULT_FORMAT) -> Iterator[list[Sequence[float]]]: """Context manager for polyline points. Returns a standard Python list of points, according to the format string. All coordinates in :ref:`OCS`. Args: format: format string, see `format codes`_ """ points = self.get_points(format=format) yield points self.set_points(points, format=format) def get_points(self, format: str = DEFAULT_FORMAT) -> list[Sequence[float]]: """Returns all points as list of tuples, format specifies a user defined point format. All points in :ref:`OCS` as (x, y) tuples (:attr:`dxf.elevation` is the z-axis value). Args: format: format string, default is "xyseb", see `format codes`_ """ return [format_point(p, format=format) for p in self.lwpoints] def set_points( self, points: Iterable[Sequence[float]], format: str = DEFAULT_FORMAT ) -> None: """Remove all points and append new `points`. All coordinates in :ref:`OCS`. Args: points: iterable of point, point is (x, y, [start_width, [end_width, [bulge]]]) tuple format: format string, default is "xyseb", see `format codes`_ """ self.lwpoints.clear() self.append_points(points, format=format) def clear(self) -> None: """Remove all points.""" self.lwpoints.clear() def transform(self, m: Matrix44) -> LWPolyline: """Transform the LWPOLYLINE entity by transformation matrix `m` inplace. A non-uniform scaling is not supported if the entity contains circular arc segments (bulges). Args: m: transformation :class:`~ezdxf.math.Matrix44` Raises: NonUniformScalingError: for non-uniform scaling of entity containing circular arc segments (bulges) """ dxf = self.dxf ocs = OCSTransform(self.dxf.extrusion, m) if not ocs.scale_uniform and self.has_arc: raise NonUniformScalingError( "LWPOLYLINE containing arcs (bulges) does not support non uniform scaling" ) # The caller function has to catch this exception and explode the # LWPOLYLINE into LINE and ELLIPSE entities. vertices = list(ocs.transform_vertex(v) for v in self.vertices_in_ocs()) lwpoints = [] for v, p in zip(vertices, self.lwpoints): _, _, start_width, end_width, bulge = p # assume a uniform scaling! start_width = ocs.transform_width(start_width) end_width = ocs.transform_width(end_width) lwpoints.append((v.x, v.y, start_width, end_width, bulge)) self.set_points(lwpoints) # All new OCS vertices must have the same z-axis, which is the elevation # of the polyline: if vertices: dxf.elevation = vertices[0].z if dxf.hasattr("const_width"): # assume a uniform scaling! dxf.const_width = ocs.transform_width(dxf.const_width) if dxf.hasattr("thickness"): dxf.thickness = ocs.transform_thickness(dxf.thickness) dxf.extrusion = ocs.new_extrusion self.post_transform(m) return self def virtual_entities(self) -> Iterator[Union[Line, Arc]]: """Yields the graphical representation of LWPOLYLINE as virtual DXF primitives (LINE or ARC). These virtual entities are located at the original location, but are not stored in the entity database, have no handle and are not assigned to any layout. """ for e in virtual_lwpolyline_entities(self): e.set_source_of_copy(self) yield e def explode(self, target_layout: Optional[BaseLayout] = None) -> EntityQuery: """Explode the LWPOLYLINE entity as DXF primitives (LINE or ARC) into the target layout, if the target layout is ``None``, the target layout is the layout of the source entity. This method destroys the source entity. Returns an :class:`~ezdxf.query.EntityQuery` container referencing all DXF primitives. Args: target_layout: target layout for the DXF primitives, ``None`` for same layout as the source entity. """ return explode_entity(self, target_layout) class LWPolylinePoints(VertexArray): __slots__ = ("values",) VERTEX_CODE = 10 START_WIDTH_CODE = 40 END_WIDTH_CODE = 41 BULGE_CODE = 42 VERTEX_SIZE = 5 @classmethod def from_tags(cls, tags: Iterable[DXFTag]) -> tuple[Self, Tags]: # type: ignore """Setup point array from tags.""" def build_vertex(point: list[float]) -> list[float]: point.append(attribs.get(cls.START_WIDTH_CODE, 0)) point.append(attribs.get(cls.END_WIDTH_CODE, 0)) point.append(attribs.get(cls.BULGE_CODE, 0)) return point unprocessed_tags = Tags() vertices: list[Sequence[float]]= [] point: list[float] | None = None attribs: dict[int, float]= {} for tag in tags: if tag.code in LWPOINTCODES: if tag.code == 10: if point is not None: vertices.append(build_vertex(point)) # just use x- and y-axis point = list(tag.value[0:2]) attribs = {} else: attribs[tag.code] = tag.value else: unprocessed_tags.append(tag) if point is not None: vertices.append(build_vertex(point)) return cls(data=vertices), unprocessed_tags def append(self, point: Sequence[float], format: str = DEFAULT_FORMAT) -> None: super().append(compile_array(point, format=format)) def dxftags(self) -> Iterator[DXFTag]: for point in self: x, y, start_width, end_width, bulge = point yield DXFVertex(self.VERTEX_CODE, (x, y)) if start_width or end_width: # Export always start- and end width together, # required for BricsCAD but not AutoCAD! yield DXFTag(self.START_WIDTH_CODE, start_width) yield DXFTag(self.END_WIDTH_CODE, end_width) if bulge: yield DXFTag(self.BULGE_CODE, bulge) def format_point(point: Sequence[float], format: str = "xyseb") -> Sequence[float]: """Reformat point components. Format codes: - ``x`` = x-coordinate - ``y`` = y-coordinate - ``s`` = start width - ``e`` = end width - ``b`` = bulge value - ``v`` = (x, y) as tuple Args: point: list or tuple of (x, y, start_width, end_width, bulge) format: format string, default is "xyseb" Returns: Sequence[float]: tuple of selected components """ x, y, s, e, b = point v = (x, y) vars = locals() return tuple(vars[code] for code in format.lower() if code in FORMAT_CODES) def compile_array(data: Sequence[float], format="xyseb") -> array.array: """Gather point components from input data. Format codes: - ``x`` = x-coordinate - ``y`` = y-coordinate - ``s`` = start width - ``e`` = end width - ``b`` = bulge value - ``v`` = (x, y [,z]) tuple (z-axis is ignored) Args: data: list or tuple of point components format: format string, default is "xyseb" Returns: array.array: array.array('d', (x, y, start_width, end_width, bulge)) """ a = array.array("d", (0.0, 0.0, 0.0, 0.0, 0.0)) format = [code for code in format.lower() if code in FORMAT_CODES] for code, value in zip(format, data): if code not in FORMAT_CODES: continue if code == "v": vertex = Vec3(value) a[0] = vertex.x a[1] = vertex.y else: a["xyseb".index(code)] = value return a