1# Copyright (c) 2019-2020, Manfred Moitzi 2# License: MIT-License 3from typing import TYPE_CHECKING, Iterable, cast, Union, List, Set 4from contextlib import contextmanager 5import logging 6from ezdxf.lldxf import validator, const 7from ezdxf.lldxf.attributes import ( 8 DXFAttr, DXFAttributes, DefSubclass, RETURN_DEFAULT, group_code_mapping, 9) 10from ezdxf.audit import AuditError 11from .dxfentity import base_class, SubclassProcessor, DXFEntity 12from .dxfobj import DXFObject 13from .factory import register_entity 14from .objectcollection import ObjectCollection 15 16logger = logging.getLogger('ezdxf') 17 18if TYPE_CHECKING: 19 from ezdxf.eztypes import ( 20 TagWriter, Drawing, DXFNamespace, Auditor, EntityDB, 21 ) 22 23__all__ = ['DXFGroup', 'GroupCollection'] 24 25acdb_group = DefSubclass('AcDbGroup', { 26 # Group description 27 'description': DXFAttr(300, default=''), 28 29 # 1 = Unnamed 30 # 0 = Named 31 'unnamed': DXFAttr( 32 70, default=1, validator=validator.is_integer_bool, 33 fixer=RETURN_DEFAULT, 34 ), 35 36 # 1 = Selectable 37 # 0 = Not selectable 38 'selectable': DXFAttr( 39 71, default=1, 40 validator=validator.is_integer_bool, 41 fixer=RETURN_DEFAULT, 42 ), 43 44 # 340: Hard-pointer handle to entity in group (one entry per object) 45}) 46acdb_group_group_codes = group_code_mapping(acdb_group) 47GROUP_ITEM_CODE = 340 48 49 50@register_entity 51class DXFGroup(DXFObject): 52 """ Groups are not allowed in block definitions, and each entity can only 53 reside in one group, so cloning of groups creates also new entities. 54 55 """ 56 DXFTYPE = 'GROUP' 57 DXFATTRIBS = DXFAttributes(base_class, acdb_group) 58 59 def __init__(self): 60 super().__init__() 61 self._handles: Set[str] = set() # only needed at the loading stage 62 self._data: List[DXFEntity] = [] 63 64 def copy(self): 65 raise const.DXFTypeError('Copying of GROUP not supported.') 66 67 def load_dxf_attribs(self, 68 processor: SubclassProcessor = None) -> 'DXFNamespace': 69 dxf = super().load_dxf_attribs(processor) 70 if processor: 71 tags = processor.fast_load_dxfattribs( 72 dxf, acdb_group_group_codes, 1, log=False) 73 self.load_group(tags) 74 return dxf 75 76 def load_group(self, tags): 77 for code, value in tags: 78 if code == GROUP_ITEM_CODE: 79 # First store handles, because at this point, objects 80 # are not stored in the EntityDB: 81 self._handles.add(value) 82 83 def preprocess_export(self, tagwriter: 'TagWriter') -> bool: 84 self.purge(self.doc.entitydb) 85 return True # export even empty groups 86 87 def export_entity(self, tagwriter: 'TagWriter') -> None: 88 """ Export entity specific data as DXF tags. """ 89 super().export_entity(tagwriter) 90 tagwriter.write_tag2(const.SUBCLASS_MARKER, acdb_group.name) 91 self.dxf.export_dxf_attribs(tagwriter, [ 92 'description', 'unnamed', 'selectable']) 93 self.export_group(tagwriter) 94 95 def export_group(self, tagwriter: 'TagWriter'): 96 for entity in self._data: 97 tagwriter.write_tag2(GROUP_ITEM_CODE, entity.dxf.handle) 98 99 def __iter__(self) -> Iterable[DXFEntity]: 100 """ Iterate over all DXF entities in :class:`DXFGroup` as instances of 101 :class:`DXFGraphic` or inherited (LINE, CIRCLE, ...). 102 103 """ 104 return (e for e in self._data if e.is_alive) 105 106 def __len__(self) -> int: 107 """ Returns the count of DXF entities in :class:`DXFGroup`. """ 108 return len(self._data) 109 110 def __getitem__(self, item): 111 """ Returns entities by standard Python indexing and slicing. """ 112 return self._data[item] 113 114 def __contains__(self, item: Union[str, DXFEntity]) -> bool: 115 """ Returns ``True`` if item is in :class:`DXFGroup`. `item` has to be 116 a handle string or an object of type :class:`DXFEntity` or inherited. 117 118 """ 119 handle = item if isinstance(item, str) else item.dxf.handle 120 return handle in set(self.handles()) 121 122 def handles(self) -> Iterable[str]: 123 """ Iterable of handles of all DXF entities in :class:`DXFGroup`. """ 124 return (entity.dxf.handle for entity in self) 125 126 def post_load_hook(self, doc: 'Drawing') -> None: 127 super().post_load_hook(doc) 128 db_get = doc.entitydb.get 129 130 def entities(): 131 for handle in self._handles: 132 entity = db_get(handle) 133 if entity and entity.is_alive: 134 yield entity 135 136 try: 137 self.set_data(entities()) 138 except const.DXFStructureError as e: 139 logger.error(str(e)) 140 del self._handles # all referenced entities are stored in _data 141 142 @contextmanager 143 def edit_data(self) -> List[DXFEntity]: 144 """ Context manager which yields all the group entities as 145 standard Python list:: 146 147 with group.edit_data() as data: 148 # add new entities to a group 149 data.append(modelspace.add_line((0, 0), (3, 0))) 150 # remove last entity from a group 151 data.pop() 152 153 """ 154 data = list(self) 155 yield data 156 self.set_data(data) 157 158 def set_data(self, entities: Iterable[DXFEntity]) -> None: 159 """ Set `entities` as new group content, entities should be an iterable 160 :class:`DXFGraphic` or inherited (LINE, CIRCLE, ...). 161 Raises :class:`DXFValueError` if not all entities be on the same layout 162 (modelspace or any paperspace layout but not block) 163 164 """ 165 entities = list(entities) 166 if not all_entities_on_same_layout(entities): 167 raise const.DXFStructureError( 168 "All entities have to be in the same layout and are not allowed" 169 " to be in a block layout." 170 ) 171 self.clear() 172 self._data = entities 173 174 def extend(self, entities: Iterable[DXFEntity]) -> None: 175 """ Add `entities` to :class:`DXFGroup`. """ 176 self._data.extend(entities) 177 178 def clear(self) -> None: 179 """ Remove all entities from :class:`DXFGroup`, does not delete any 180 drawing entities referenced by this group. 181 182 """ 183 self._data = [] 184 185 def audit(self, auditor: 'Auditor') -> None: 186 """ Remove invalid handles from :class:`DXFGroup`. 187 188 Invalid handles are: deleted entities, not all entities in the same 189 layout or entities in a block layout. 190 191 """ 192 # Remove destroyed or invalid entities: 193 self.purge(auditor.entitydb) 194 if not all_entities_on_same_layout(self._data): 195 auditor.fixed_error( 196 code=AuditError.GROUP_ENTITIES_IN_DIFFERENT_LAYOUTS, 197 message=f'Cleared {str(self)}, not all entities are located in ' 198 f'the same layout.', 199 ) 200 self.clear() 201 202 def _has_valid_owner(self, entity, db: 'EntityDB') -> bool: 203 # no owner -> no layout association 204 if entity.dxf.owner is None: 205 return False 206 owner = db.get(entity.dxf.owner) 207 # owner does not exist or is destroyed -> no layout association 208 if owner is None or not owner.is_alive: 209 return False 210 # owner block_record.layout is 0 if entity is in a block definition, 211 # which is not allowed: 212 valid = owner.dxf.layout != '0' 213 if not valid: 214 logger.debug( 215 f"{str(entity)} in {str(self)} is located in a block layout, " 216 f"which is not allowed") 217 return valid 218 219 def _filter_invalid_entities(self, db: 'EntityDB') -> List[DXFEntity]: 220 assert db is not None 221 return [e for e in self._data 222 if e.is_alive and self._has_valid_owner(e, db)] 223 224 def purge(self, db: 'EntityDB') -> None: 225 """ Remove invalid group entities. """ 226 self._data = self._filter_invalid_entities(db) 227 228 229def all_entities_on_same_layout(entities: Iterable[DXFEntity]): 230 """ Check if all entities are on the same layout (model space or any paper 231 layout but not block). 232 233 """ 234 owners = set(entity.dxf.owner for entity in entities) 235 # 0 for no entities; 1 for all entities on the same layout 236 return len(owners) < 2 237 238 239class GroupCollection(ObjectCollection): 240 def __init__(self, doc: 'Drawing'): 241 super().__init__(doc, dict_name='ACAD_GROUP', object_type='GROUP') 242 self._next_unnamed_number = 0 243 244 def groups(self) -> Iterable[DXFGroup]: 245 """ Iterable of all existing groups. """ 246 for name, group in self: 247 yield group 248 249 def next_name(self) -> str: 250 name = self._next_name() 251 while name in self: 252 name = self._next_name() 253 return name 254 255 def _next_name(self) -> str: 256 self._next_unnamed_number += 1 257 return f"*A{self._next_unnamed_number}" 258 259 def new(self, name: str = None, description: str = "", 260 selectable: bool = True) -> DXFGroup: 261 r""" Creates a new group. If `name` is ``None`` an unnamed group is 262 created, which has an automatically generated name like "\*Annnn". 263 264 Args: 265 name: group name as string 266 description: group description as string 267 selectable: group is selectable if ``True`` 268 269 """ 270 if name in self: 271 raise const.DXFValueError(f"GROUP '{name}' already exists.") 272 273 if name is None: 274 name = self.next_name() 275 unnamed = 1 276 else: 277 unnamed = 0 278 # The group name isn't stored in the group entity itself. 279 dxfattribs = { 280 'description': description, 281 'unnamed': unnamed, 282 'selectable': int(bool(selectable)), 283 } 284 return cast(DXFGroup, self._new(name, dxfattribs)) 285 286 def delete(self, group: Union[DXFGroup, str]) -> None: 287 """ Delete `group`, `group` can be an object of type :class:`DXFGroup` 288 or a group name as string. 289 290 """ 291 # Delete group by name: 292 if isinstance(group, str): 293 name = group 294 elif group.dxftype() == 'GROUP': 295 name = get_group_name(group, self.entitydb) 296 else: 297 raise TypeError(group.dxftype()) 298 299 if name in self: 300 super().delete(name) 301 else: 302 raise const.DXFValueError("GROUP not in group table registered.") 303 304 def audit(self, auditor: 'Auditor') -> None: 305 """ Removes empty groups and invalid handles from all groups. """ 306 trash = [] 307 for name, group in self: 308 group.audit(auditor) 309 if not len(group): # remove empty group 310 # do not delete groups while iterating over groups! 311 trash.append(name) 312 313 # now delete empty groups 314 for name in trash: 315 auditor.fixed_error( 316 code=AuditError.REMOVE_EMPTY_GROUP, 317 message=f'Removed empty group "{name}".', 318 ) 319 self.delete(name) 320 321 322def get_group_name(group: DXFGroup, db: 'EntityDB') -> str: 323 """ Get name of `group`. """ 324 group_table = cast('Dictionary', db[group.dxf.owner]) 325 for name, entity in group_table.items(): 326 if entity is group: 327 return name 328