540 lines
18 KiB
Python
540 lines
18 KiB
Python
# 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
|