# Copyright (c) 2021-2023, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Iterable, Optional, cast, TYPE_CHECKING import abc import math from ezdxf.entities import DXFEntity, Insert, get_font_name from ezdxf.lldxf import const from ezdxf.enums import TextEntityAlignment from ezdxf.math import Vec3, UCS, Z_AXIS, X_AXIS, BoundingBox from ezdxf.path import Path, make_path, from_vertices, precise_bbox from ezdxf.render import MeshBuilder, MeshVertexMerger, TraceBuilder from ezdxf.protocols import SupportsVirtualEntities, virtual_entities from ezdxf.tools.text import ( TextLine, unified_alignment, plain_text, text_wrap, estimate_mtext_content_extents, ) from ezdxf.fonts import fonts if TYPE_CHECKING: from ezdxf.entities import LWPolyline, Polyline, MText, Text __all__ = [ "make_primitive", "recursive_decompose", "to_primitives", "to_vertices", "to_control_vertices", "to_paths", "to_meshes", ] class Primitive(abc.ABC): """It is not efficient to create the Path() or MeshBuilder() representation by default. For some entities it's just not needed (LINE, POINT) and for others the builtin flattening() method is more efficient or accurate than using a Path() proxy object. (ARC, CIRCLE, ELLIPSE, SPLINE). The `max_flattening_distance` defines the max distance between the approximation line and the original curve. Use argument `max_flattening_distance` to override the default value, or set the value by direct attribute access. """ max_flattening_distance: float = 0.01 def __init__( self, entity: DXFEntity, max_flattening_distance: Optional[float] = None ): self.entity: DXFEntity = entity # Path representation for linear entities: self._path: Optional[Path] = None # MeshBuilder representation for mesh based entities: # PolygonMesh, PolyFaceMesh, Mesh self._mesh: Optional[MeshBuilder] = None if max_flattening_distance is not None: self.max_flattening_distance = max_flattening_distance @property def is_empty(self) -> bool: """Returns `True` if represents an empty primitive which do not yield any vertices. """ if self._mesh: return len(self._mesh.vertices) == 0 return self.path is None # on demand calculations! @property def path(self) -> Optional[Path]: """:class:`~ezdxf.path.Path` representation or ``None``, idiom to check if is a path representation (could be empty):: if primitive.path is not None: process(primitive.path) """ return None @property def mesh(self) -> Optional[MeshBuilder]: """:class:`~ezdxf.render.mesh.MeshBuilder` representation or ``None``, idiom to check if is a mesh representation (could be empty):: if primitive.mesh is not None: process(primitive.mesh) """ return None @abc.abstractmethod def vertices(self) -> Iterable[Vec3]: """Yields all vertices of the path/mesh representation as :class:`~ezdxf.math.Vec3` objects. """ pass def bbox(self, fast=False) -> BoundingBox: """Returns the :class:`~ezdxf.math.BoundingBox` of the path/mesh representation. Returns the precise bounding box for the path representation if `fast` is ``False``, otherwise the bounding box for Bézier curves is based on their control points. """ if self.mesh: return BoundingBox(self.vertices()) path = self.path if path: if fast: return BoundingBox(path.control_vertices()) return precise_bbox(path) return BoundingBox() class EmptyPrimitive(Primitive): @property def is_empty(self) -> bool: return True def vertices(self) -> Iterable[Vec3]: return [] class ConvertedPrimitive(Primitive): """Base class for all DXF entities which store the path/mesh representation at instantiation. """ def __init__(self, entity: DXFEntity): super().__init__(entity) self._convert_entity() @abc.abstractmethod def _convert_entity(self): """This method creates the path/mesh representation.""" pass @property def path(self) -> Optional[Path]: return self._path @property def mesh(self) -> Optional[MeshBuilder]: return self._mesh def vertices(self) -> Iterable[Vec3]: if self.path: yield from self._path.flattening(self.max_flattening_distance) # type: ignore elif self.mesh: yield from self._mesh.vertices # type: ignore class CurvePrimitive(Primitive): @property def path(self) -> Optional[Path]: """Create path representation on demand.""" if self._path is None: self._path = make_path(self.entity) return self._path def vertices(self) -> Iterable[Vec3]: # Not faster but more precise, because cubic bezier curves do not # perfectly represent elliptic arcs (CIRCLE, ARC, ELLIPSE). # SPLINE: cubic bezier curves do not perfectly represent splines with # degree != 3. yield from self.entity.flattening(self.max_flattening_distance) # type: ignore class LinePrimitive(Primitive): # TODO: apply thickness if not 0 @property def path(self) -> Optional[Path]: """Create path representation on demand.""" if self._path is None: self._path = make_path(self.entity) return self._path def vertices(self) -> Iterable[Vec3]: e = self.entity yield e.dxf.start yield e.dxf.end def bbox(self, fast=False) -> BoundingBox: e = self.entity return BoundingBox((e.dxf.start, e.dxf.end)) class LwPolylinePrimitive(ConvertedPrimitive): # TODO: apply thickness if not 0 def _convert_entity(self) -> None: e: LWPolyline = cast("LWPolyline", self.entity) if e.has_width: # use a mesh representation: # TraceBuilder operates in OCS! ocs = e.ocs() elevation = e.dxf.elevation tb = TraceBuilder.from_polyline(e) mb = MeshVertexMerger() # merges coincident vertices for face in tb.faces_wcs(ocs, elevation): mb.add_face(face) self._mesh = MeshBuilder.from_builder(mb) else: # use a path representation to support bulges! self._path = make_path(e) class PointPrimitive(Primitive): @property def path(self) -> Optional[Path]: """Create path representation on demand. :class:`Path` can not represent a point, a :class:`Path` with only a start point yields not vertices! """ if self._path is None: self._path = Path(self.entity.dxf.location) return self._path def vertices(self) -> Iterable[Vec3]: yield self.entity.dxf.location def bbox(self, fast=False) -> BoundingBox: return BoundingBox((self.entity.dxf.location,)) class MeshPrimitive(ConvertedPrimitive): def _convert_entity(self): self._mesh = MeshBuilder.from_mesh(self.entity) class QuadrilateralPrimitive(ConvertedPrimitive): # TODO: apply thickness if not 0 def _convert_entity(self): self._path = make_path(self.entity) class PolylinePrimitive(ConvertedPrimitive): # TODO: apply thickness if not 0 def _convert_entity(self) -> None: e: Polyline = cast("Polyline", self.entity) if e.is_2d_polyline and e.has_width: # TraceBuilder operates in OCS! ocs = e.ocs() elevation = e.dxf.elevation.z tb = TraceBuilder.from_polyline(e) mb = MeshVertexMerger() # merges coincident vertices for face in tb.faces_wcs(ocs, elevation): mb.add_face(face) self._mesh = MeshBuilder.from_builder(mb) elif e.is_2d_polyline or e.is_3d_polyline: self._path = make_path(e) else: m = MeshVertexMerger.from_polyface(e) # type: ignore self._mesh = MeshBuilder.from_builder(m) class HatchPrimitive(ConvertedPrimitive): def _convert_entity(self): self._path = make_path(self.entity) DESCENDER_FACTOR = 0.333 # from TXT SHX font - just guessing X_HEIGHT_FACTOR = 0.666 # from TXT SHX font - just guessing class TextLinePrimitive(ConvertedPrimitive): def _convert_entity(self) -> None: """Calculates the rough border path for a single line text. Calculation is based on a monospaced font and therefore the border path is just an educated guess. Vertical text generation and oblique angle is ignored. """ def text_rotation(): if fit_or_aligned and not p1.isclose(p2): return (p2 - p1).angle else: return math.radians(text.dxf.rotation) def location(): if fit_or_aligned: return p1.lerp(p2, factor=0.5) return p1 text = cast("Text", self.entity) if text.dxftype() == "ATTDEF": # ATTDEF outside of a BLOCK renders the tag rather than the value content = text.dxf.tag else: content = text.dxf.text content = plain_text(content) if len(content) == 0: # empty path - does not render any vertices! self._path = Path() return font = fonts.make_font( get_font_name(text), text.dxf.height, text.dxf.width ) text_line = TextLine(content, font) alignment, p1, p2 = text.get_placement() if p2 is None: p2 = p1 fit_or_aligned = ( alignment == TextEntityAlignment.FIT or alignment == TextEntityAlignment.ALIGNED ) if text.dxf.halign > 2: # ALIGNED=3, MIDDLE=4, FIT=5 text_line.stretch(alignment, p1, p2) halign, valign = unified_alignment(text) mirror_x = -1 if text.is_backward else 1 mirror_y = -1 if text.is_upside_down else 1 oblique: float = math.radians(text.dxf.oblique) corner_vertices = text_line.corner_vertices( location(), halign, valign, angle=text_rotation(), scale=(mirror_x, mirror_y), oblique=oblique, ) ocs = text.ocs() self._path = from_vertices( ocs.points_to_wcs(corner_vertices), close=True, ) class MTextPrimitive(ConvertedPrimitive): def _convert_entity(self) -> None: """Calculates the rough border path for a MTEXT entity. Calculation is based on a mono-spaced font and therefore the border path is just an educated guess. Most special features of MTEXT is not supported. """ def get_content() -> list[str]: text = mtext.plain_text(split=False) return text_wrap(text, box_width, font.text_width) # type: ignore def get_max_str() -> str: return max(content, key=lambda s: len(s)) def get_rect_width() -> float: if box_width: return box_width s = get_max_str() if len(s) == 0: s = " " return font.text_width(s) def get_rect_height() -> float: line_height = font.measurements.total_height cap_height = font.measurements.cap_height # Line spacing factor: Percentage of default (3-on-5) line # spacing to be applied. # thx to mbway: multiple of cap_height between the baseline of the # previous line and the baseline of the next line # 3-on-5 line spacing = 5/3 = 1.67 line_spacing = cap_height * mtext.dxf.line_spacing_factor * 1.67 spacing = line_spacing - line_height line_count = len(content) return line_height * line_count + spacing * (line_count - 1) def get_ucs() -> UCS: """Create local coordinate system: origin = insertion point z-axis = extrusion vector x-axis = text_direction or text rotation, text rotation requires extrusion vector == (0, 0, 1) or treatment like an OCS? """ origin = mtext.dxf.insert z_axis = mtext.dxf.extrusion # default is Z_AXIS x_axis = X_AXIS if mtext.dxf.hasattr("text_direction"): x_axis = mtext.dxf.text_direction elif mtext.dxf.hasattr("rotation"): # TODO: what if extrusion vector is not (0, 0, 1) x_axis = Vec3.from_deg_angle(mtext.dxf.rotation) z_axis = Z_AXIS return UCS(origin=origin, ux=x_axis, uz=z_axis) def get_shift_factors(): halign, valign = unified_alignment(mtext) shift_x = 0 shift_y = 0 if halign == const.CENTER: shift_x = -0.5 elif halign == const.RIGHT: shift_x = -1.0 if valign == const.MIDDLE: shift_y = 0.5 elif valign == const.BOTTOM: shift_y = 1.0 return shift_x, shift_y def get_corner_vertices() -> Iterable[Vec3]: """Create corner vertices in the local working plan, where the insertion point is the origin. """ if columns: rect_width = columns.total_width rect_height = columns.total_height if rect_width == 0.0 or rect_height == 0.0: # Reliable sources like AutoCAD and BricsCAD do write # correct total_width and total_height values! w, h = _estimate_column_extents(mtext) if rect_width == 0.0: rect_width = w if rect_height == 0.0: rect_height = h else: rect_width = mtext.dxf.get("rect_width", get_rect_width()) rect_height = mtext.dxf.get("rect_height", get_rect_height()) # TOP LEFT alignment: vertices = [ Vec3(0, 0), Vec3(rect_width, 0), Vec3(rect_width, -rect_height), Vec3(0, -rect_height), ] sx, sy = get_shift_factors() shift = Vec3(sx * rect_width, sy * rect_height) return (v + shift for v in vertices) mtext: MText = cast("MText", self.entity) columns = mtext.columns if columns is None: box_width = mtext.dxf.get("width", 0) font = fonts.make_font( get_font_name(mtext), mtext.dxf.char_height, 1.0 ) content: list[str] = get_content() if len(content) == 0: # empty path - does not render any vertices! self._path = Path() return ucs = get_ucs() corner_vertices = get_corner_vertices() self._path = from_vertices( ucs.points_to_wcs(corner_vertices), close=True, ) def _estimate_column_extents(mtext: MText): columns = mtext.columns assert columns is not None _content = mtext.text if columns.count > 1: _columns_content = _content.split("\\N") if len(_columns_content) > 1: _content = max(_columns_content, key=lambda t: len(t)) return estimate_mtext_content_extents( content=_content, font=fonts.MonospaceFont(mtext.dxf.char_height, 1.0), column_width=columns.width, line_spacing_factor=mtext.dxf.get_default("line_spacing_factor"), ) class ImagePrimitive(ConvertedPrimitive): def _convert_entity(self): self._path = make_path(self.entity) class ViewportPrimitive(ConvertedPrimitive): def _convert_entity(self): vp = self.entity if vp.dxf.status == 0: # Viewport is off return # empty primitive self._path = make_path(vp) # SHAPE is not supported, could not create any SHAPE entities in BricsCAD _PRIMITIVE_CLASSES = { "3DFACE": QuadrilateralPrimitive, "ARC": CurvePrimitive, # TODO: ATTRIB and ATTDEF could contain embedded MTEXT, # but this is not supported yet! "ATTRIB": TextLinePrimitive, "ATTDEF": TextLinePrimitive, "CIRCLE": CurvePrimitive, "ELLIPSE": CurvePrimitive, "HATCH": HatchPrimitive, # multi-path object "MPOLYGON": HatchPrimitive, # multi-path object "HELIX": CurvePrimitive, "IMAGE": ImagePrimitive, "LINE": LinePrimitive, "LWPOLYLINE": LwPolylinePrimitive, "MESH": MeshPrimitive, "MTEXT": MTextPrimitive, "POINT": PointPrimitive, "POLYLINE": PolylinePrimitive, "SPLINE": CurvePrimitive, "SOLID": QuadrilateralPrimitive, "TEXT": TextLinePrimitive, "TRACE": QuadrilateralPrimitive, "VIEWPORT": ViewportPrimitive, "WIPEOUT": ImagePrimitive, } def make_primitive( entity: DXFEntity, max_flattening_distance=None ) -> Primitive: """Factory to create path/mesh primitives. The `max_flattening_distance` defines the max distance between the approximation line and the original curve. Use `max_flattening_distance` to override the default value. Returns an **empty primitive** for unsupported entities. The `empty` state of a primitive can be checked by the property :attr:`is_empty`. The :attr:`path` and the :attr:`mesh` attributes of an empty primitive are ``None`` and the :meth:`vertices` method yields no vertices. """ cls = _PRIMITIVE_CLASSES.get(entity.dxftype(), EmptyPrimitive) primitive = cls(entity) if max_flattening_distance: primitive.max_flattening_distance = max_flattening_distance return primitive def recursive_decompose(entities: Iterable[DXFEntity]) -> Iterable[DXFEntity]: """Recursive decomposition of the given DXF entity collection into a flat stream of DXF entities. All block references (INSERT) and entities which provide a :meth:`virtual_entities` method will be disassembled into simple DXF sub-entities, therefore the returned entity stream does not contain any INSERT entity. Point entities will **not** be disassembled into DXF sub-entities, as defined by the current point style $PDMODE. These entity types include sub-entities and will be decomposed into simple DXF entities: - INSERT - DIMENSION - LEADER - MLEADER - MLINE Decomposition of XREF, UNDERLAY and ACAD_TABLE entities is not supported. This function does not apply the clipping path created by the XCLIP command. The function returns all entities and ignores the clipping path polygon and no entity is clipped. """ for entity in entities: if isinstance(entity, Insert): # TODO: emit internal XCLIP marker entity? if entity.mcount > 1: yield from recursive_decompose(entity.multi_insert()) else: yield from entity.attribs yield from recursive_decompose(virtual_entities(entity)) # has a required __virtual_entities__() to be rendered? elif isinstance(entity, SupportsVirtualEntities): # could contain block references: yield from recursive_decompose(virtual_entities(entity)) else: yield entity def to_primitives( entities: Iterable[DXFEntity], max_flattening_distance: Optional[float] = None, ) -> Iterable[Primitive]: """Yields all DXF entities as path or mesh primitives. Yields unsupported entities as empty primitives, see :func:`make_primitive`. Args: entities: iterable of DXF entities max_flattening_distance: override the default value """ for e in entities: yield make_primitive(e, max_flattening_distance) def to_vertices(primitives: Iterable[Primitive]) -> Iterable[Vec3]: """Yields all vertices from the given `primitives`. Paths will be flattened to create the associated vertices. See also :func:`to_control_vertices` to collect only the control vertices from the paths without flattening. """ for p in primitives: yield from p.vertices() def to_paths(primitives: Iterable[Primitive]) -> Iterable[Path]: """Yields all :class:`~ezdxf.path.Path` objects from the given `primitives`. Ignores primitives without a defined path. """ for prim in primitives: if prim.path is not None: # lazy evaluation! yield prim.path def to_meshes(primitives: Iterable[Primitive]) -> Iterable[MeshBuilder]: """Yields all :class:`~ezdxf.render.MeshBuilder` objects from the given `primitives`. Ignores primitives without a defined mesh. """ for prim in primitives: if prim.mesh is not None: yield prim.mesh def to_control_vertices(primitives: Iterable[Primitive]) -> Iterable[Vec3]: """Yields all path control vertices and all mesh vertices from the given `primitives`. Like :func:`to_vertices`, but without flattening. """ for prim in primitives: # POINT has only a start point and yields from vertices()! if prim.path: yield from prim.path.control_vertices() else: yield from prim.vertices()