# Copyright (c) 2024, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Iterable, Sequence import dataclasses from ezdxf.lldxf import const from ezdxf.lldxf.tags import Tags from ezdxf.lldxf.types import dxftag from ezdxf.entities import SpatialFilter, DXFEntity, Dictionary, Insert, XRecord from ezdxf.math import Vec2, Vec3, UVec, Z_AXIS, Matrix44, BoundingBox2d from ezdxf.entities.acad_xrec_roundtrip import RoundtripXRecord __all__ = ["get_spatial_filter", "XClip", "ClippingPath"] ACAD_FILTER = "ACAD_FILTER" ACAD_XREC_ROUNDTRIP = "ACAD_XREC_ROUNDTRIP" ACAD_INVERTEDCLIP_ROUNDTRIP = "ACAD_INVERTEDCLIP_ROUNDTRIP" ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE = "ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE" SPATIAL = "SPATIAL" @dataclasses.dataclass class ClippingPath: """Stores the SPATIAL_FILTER clipping paths in original form that I still don't fully understand for `inverted` clipping paths. All boundary paths are simple polygons as a sequence of :class:`~ezdxf.math.Vec2`. Attributes: vertices: Contains the boundary polygon for regular clipping paths. Contains the outer boundary path for inverted clippings paths - but not always! inverted_clip: Contains the inner boundary for inverted clipping paths - but not always! inverted_clip_compare: Contains the combined inner- and the outer boundaries for inverted clipping paths - but not always! is_inverted_clip: ``True`` for inverted clipping paths """ vertices: Sequence[Vec2] = tuple() inverted_clip: Sequence[Vec2] = tuple() inverted_clip_compare: Sequence[Vec2] = tuple() is_inverted_clip: bool = False def inner_polygon(self) -> Sequence[Vec2]: """Returns the inner clipping polygon as sequence of Vec2.""" # The exact data structure of inverted clippings polygons is still not # clear to me, so use the smallest polygon as inner clipping polygon. if not self.is_inverted_clip: return self.vertices inner_polygon = self.vertices if bbox_area(self.inverted_clip) < bbox_area(inner_polygon): inner_polygon = self.inverted_clip return inner_polygon def outer_bounds(self) -> BoundingBox2d: """Returns the maximum extents as BoundingBox2d.""" if not self.is_inverted_clip: return BoundingBox2d(self.vertices) # The exact data structure of inverted clippings polygons is still not # clear to me, this is my best guess: return BoundingBox2d(self.inverted_clip_compare) class XClip: """Helper class to manage the clipping path of INSERT entities. Provides a similar functionality as the XCLIP command in CAD applications. .. important:: This class handles only 2D clipping paths. The visibility of the clipping path can be set individually for each block reference, but the HEADER variable $XCLIPFRAME ultimately determines whether the clipping path is displayed or plotted by the application: === =============== === 0 not displayed not plotted 1 displayed not plotted 2 displayed plotted === =============== === The default setting is 2. """ def __init__(self, insert: Insert) -> None: if not isinstance(insert, Insert): raise const.DXFTypeError(f"INSERT entity required, got {str(insert)}") self._insert = insert self._spatial_filter = get_spatial_filter(insert) def get_spatial_filter(self) -> SpatialFilter | None: """Returns the underlaying SPATIAL_FILTER entity if the INSERT entity has a clipping path and returns ``None`` otherwise. """ return self._spatial_filter def get_xclip_frame_policy(self) -> int: policy: int = 2 if self._insert.doc is not None: policy = self._insert.doc.header.get("$XCLIPFRAME", 2) return policy @property def has_clipping_path(self) -> bool: """Returns if the INSERT entity has a clipping path.""" return self._spatial_filter is not None @property def is_clipping_enabled(self) -> bool: """Returns ``True`` if block reference clipping is enabled.""" if isinstance(self._spatial_filter, SpatialFilter): return bool(self._spatial_filter.dxf.is_clipping_enabled) return False @property def is_inverted_clip(self) -> bool: """Returns ``True`` if clipping path is inverted.""" xrec = get_roundtrip_xrecord(self._spatial_filter) if xrec is None: return False return xrec.has_section(ACAD_INVERTEDCLIP_ROUNDTRIP) def enable_clipping(self) -> None: """Enable block reference clipping.""" if isinstance(self._spatial_filter, SpatialFilter): self._spatial_filter.dxf.is_clipping_enabled = 1 def disable_clipping(self) -> None: """Disable block reference clipping.""" if isinstance(self._spatial_filter, SpatialFilter): self._spatial_filter.dxf.is_clipping_enabled = 0 def get_block_clipping_path(self) -> ClippingPath: """Returns the clipping path in block coordinates (relative to the block origin).""" vertices: Sequence[Vec2] = [] if not isinstance(self._spatial_filter, SpatialFilter): return ClippingPath() m = self._spatial_filter.inverse_insert_matrix vertices = Vec2.tuple( m.transform_vertices(self._spatial_filter.boundary_vertices) ) if len(vertices) == 2: vertices = _rect_path(vertices) clipping_path = ClippingPath(vertices, is_inverted_clip=False) xrec = get_roundtrip_xrecord(self._spatial_filter) if isinstance(xrec, RoundtripXRecord): clipping_path.inverted_clip = get_roundtrip_vertices( xrec, ACAD_INVERTEDCLIP_ROUNDTRIP, m ) clipping_path.inverted_clip_compare = get_roundtrip_vertices( xrec, ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE, m ) clipping_path.is_inverted_clip = True return clipping_path def get_wcs_clipping_path(self) -> ClippingPath: """Returns the clipping path in WCS coordinates (relative to the WCS origin) as 2D path projected onto the xy-plane. """ vertices: Sequence[Vec2] = tuple() if not isinstance(self._spatial_filter, SpatialFilter): return ClippingPath(vertices, vertices) block_clipping_path = self.get_block_clipping_path() m = self._insert.matrix44() vertices = Vec2.tuple(m.transform_vertices(block_clipping_path.vertices)) if len(vertices) == 2: # rectangle by diagonal corner vertices vertices = BoundingBox2d(vertices).rect_vertices() wcs_clipping_path = ClippingPath( vertices, is_inverted_clip=block_clipping_path.is_inverted_clip ) if block_clipping_path.is_inverted_clip: inverted_clip = Vec2.tuple( m.transform_vertices(block_clipping_path.inverted_clip) ) if len(inverted_clip) == 2: # rectangle by diagonal corner vertices inverted_clip = BoundingBox2d(inverted_clip).rect_vertices() wcs_clipping_path.inverted_clip = inverted_clip wcs_clipping_path.inverted_clip_compare = Vec2.tuple( m.transform_vertices(block_clipping_path.inverted_clip_compare) ) return wcs_clipping_path def set_block_clipping_path(self, vertices: Iterable[UVec]) -> None: """Set clipping path in block coordinates (relative to block origin). The clipping path is located in the xy-plane, the z-axis of all vertices will be ignored. The clipping path doesn't have to be closed (first vertex != last vertex). Two vertices define a rectangle where the sides are parallel to x- and y-axis. Raises: DXFValueError: clipping path has less than two vertrices """ if self._spatial_filter is None: self._spatial_filter = new_spatial_filter(self._insert) spatial_filter = self._spatial_filter spatial_filter.set_boundary_vertices(vertices) spatial_filter.dxf.origin = Vec3(0, 0, 0) spatial_filter.dxf.extrusion = Z_AXIS spatial_filter.dxf.has_front_clipping_plane = 0 spatial_filter.dxf.front_clipping_plane_distance = 0.0 spatial_filter.dxf.has_back_clipping_plane = 0 spatial_filter.dxf.back_clipping_plane_distance = 0.0 # The clipping path set by ezdxf is always relative to the block origin and # therefore both transformation matrices are the identity matrix - which does # nothing. m = Matrix44() spatial_filter.set_inverse_insert_matrix(m) spatial_filter.set_transform_matrix(m) self._discard_inverted_clip() def set_wcs_clipping_path(self, vertices: Iterable[UVec]) -> None: """Set clipping path in WCS coordinates (relative to WCS origin). The clipping path is located in the xy-plane, the z-axis of all vertices will be ignored. The clipping path doesn't have to be closed (first vertex != last vertex). Two vertices define a rectangle where the sides are parallel to x- and y-axis. Raises: DXFValueError: clipping path has less than two vertrices ZeroDivisionError: Block reference transformation matrix is not invertible """ m = self._insert.matrix44() try: m.inverse() except ZeroDivisionError: raise ZeroDivisionError( "Block reference transformation matrix is not invertible." ) _vertices = Vec2.list(vertices) if len(_vertices) == 2: _vertices = _rect_path(_vertices) self.set_block_clipping_path(m.transform_vertices(_vertices)) def invert_clipping_path( self, extents: Iterable[UVec] | None = None, *, ignore_acad_compatibility=False ) -> None: """Invert clipping path. (experimental feature) The outer boundary is defined by the bounding box of the given `extents` vertices or auto-detected if `extents` is ``None``. The `extents` are BLOCK coordinates. Requires an existing clipping path and that clipping path cannot be inverted. .. warning:: You have to set the flag `ignore_acad_compatibility` to ``True`` to use this feature. AutoCAD will not load DXF files with inverted clipping paths created by ezdxf!!!! """ if ignore_acad_compatibility is False: return current_clipping_path = self.get_block_clipping_path() if len(current_clipping_path.vertices) < 2: raise const.DXFValueError("no clipping path set") if current_clipping_path.is_inverted_clip: raise const.DXFValueError("clipping path is already inverted") assert self._insert.doc is not None self._insert.doc.add_acad_incompatibility_message( "\nAutoCAD will not load DXF files with inverted clipping paths created by ezdxf" ) grow_factor = 0.0 if extents is None: extents = self._detect_block_extents() # grow bounding box by 10%, bbox detection is not very precise for text # based entities: grow_factor = 0.1 bbox = BoundingBox2d(extents) bbox.extend(current_clipping_path.vertices) if not bbox.has_data: raise const.DXFValueError("extents not detectable") if grow_factor: bbox.grow(max(bbox.size) * grow_factor) # inverted_clip is the regular clipping path inverted_clip = current_clipping_path.vertices # construct an inverted clipping path inverted_clip_compare = _get_inverted_clip_compare_vertices(bbox, inverted_clip) # set inverted_clip_compare as regular clipping path self.set_block_clipping_path(inverted_clip_compare) self._set_inverted_clipping_path(inverted_clip, inverted_clip_compare) def _detect_block_extents(self) -> Sequence[Vec2]: from ezdxf import bbox insert = self._insert doc = insert.doc assert doc is not None, "valid DXF document required" no_vertices: Sequence[Vec2] = tuple() block = doc.blocks.get(insert.dxf.name) if block is None: return no_vertices _bbox = bbox.extents(block, fast=True) if not _bbox.has_data: return no_vertices return Vec2.tuple([_bbox.extmin, _bbox.extmax]) def _set_inverted_clipping_path( self, clip_vertices: Iterable[Vec2], compare_vertices: Iterable[Vec2] ) -> None: spatial_filter = self._spatial_filter assert isinstance(spatial_filter, SpatialFilter) xrec = get_roundtrip_xrecord(spatial_filter) if xrec is None: xrec = new_roundtrip_xrecord(spatial_filter) clip_tags = Tags(dxftag(10, Vec3(v)) for v in clip_vertices) compare_tags = Tags(dxftag(10, Vec3(v)) for v in compare_vertices) xrec.set_section(ACAD_INVERTEDCLIP_ROUNDTRIP, clip_tags) xrec.set_section(ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE, compare_tags) def discard_clipping_path(self) -> None: """Delete the clipping path. The clipping path doesn't have to exist. This method does not discard the extension dictionary of the base entity, even when its empty. """ if not isinstance(self._spatial_filter, SpatialFilter): return xdict = self._insert.get_extension_dict() xdict.discard(ACAD_FILTER) entitydb = self._insert.doc.entitydb # type: ignore assert entitydb is not None entitydb.delete_entity(self._spatial_filter) self._spatial_filter = None def _discard_inverted_clip(self) -> None: if isinstance(self._spatial_filter, SpatialFilter): self._spatial_filter.discard_extension_dict() def cleanup(self): """Discard the extension dictionary of the base entity when empty.""" self._insert.discard_empty_extension_dict() def _rect_path(vertices: Iterable[Vec2]) -> Sequence[Vec2]: """Returns the path vertices for the smallest rectangular boundary around the given vertices. """ return BoundingBox2d(vertices).rect_vertices() def _get_inverted_clip_compare_vertices( bbox: BoundingBox2d, hole: Sequence[Vec2] ) -> Sequence[Vec2]: # AutoCAD does not accept this paths and from further tests it's clear that the # geometry of the inverted clipping path is the problem not the DXF structure! from ezdxf.math.clipping import make_inverted_clipping_polygon assert (bbox.extmax is not None) and (bbox.extmin is not None) return make_inverted_clipping_polygon(inner_polygon=list(hole), outer_bounds=bbox) def get_spatial_filter(entity: DXFEntity) -> SpatialFilter | None: """Returns the underlaying SPATIAL_FILTER entity if the given `entity` has a clipping path and returns ``None`` otherwise. """ try: xdict = entity.get_extension_dict() except AttributeError: return None acad_filter = xdict.get(ACAD_FILTER) if not isinstance(acad_filter, Dictionary): return None acad_spatial_filter = acad_filter.get(SPATIAL) if isinstance(acad_spatial_filter, SpatialFilter): return acad_spatial_filter return None def new_spatial_filter(entity: DXFEntity) -> SpatialFilter: """Creates the extension dict, the sub-dictionary ACAD_FILTER and the SPATIAL_FILTER entity if not exist. """ doc = entity.doc if doc is None: raise const.DXFTypeError("Cannot add new clipping path to virtual entity.") try: xdict = entity.get_extension_dict() except AttributeError: xdict = entity.new_extension_dict() acad_filter_dict = xdict.dictionary.get_required_dict(ACAD_FILTER) spatial_filter = acad_filter_dict.get(SPATIAL) if not isinstance(spatial_filter, SpatialFilter): spatial_filter = doc.objects.add_dxf_object_with_reactor( "SPATIAL_FILTER", {"owner": acad_filter_dict.dxf.handle} ) acad_filter_dict.add(SPATIAL, spatial_filter) assert isinstance(spatial_filter, SpatialFilter) return spatial_filter def new_roundtrip_xrecord(spatial_filter: SpatialFilter) -> RoundtripXRecord: try: xdict = spatial_filter.get_extension_dict() except AttributeError: xdict = spatial_filter.new_extension_dict() xrec = xdict.get(ACAD_XREC_ROUNDTRIP) if xrec is None: xrec = xdict.add_xrecord(ACAD_XREC_ROUNDTRIP) xrec.set_reactors([xdict.handle]) assert isinstance(xrec, XRecord) return RoundtripXRecord(xrec) def get_roundtrip_xrecord( spatial_filter: SpatialFilter | None, ) -> RoundtripXRecord | None: if spatial_filter is None: return None try: xdict = spatial_filter.get_extension_dict() except AttributeError: return None xrecord = xdict.get(ACAD_XREC_ROUNDTRIP) if isinstance(xrecord, XRecord): return RoundtripXRecord(xrecord) return None def get_roundtrip_vertices( xrec: RoundtripXRecord, section_name: str, m: Matrix44 ) -> Sequence[Vec2]: tags = xrec.get_section(section_name) vertices = m.transform_vertices(Vec3(t.value) for t in tags) return Vec2.tuple(vertices) def bbox_area(vertice: Sequence[Vec2]) -> float: bbox = BoundingBox2d(vertice) if bbox.has_data: size = bbox.size return size.x * size.y return 0.0