1# Copyright (c) 2020-2021, Matthew Broadway 2# License: MIT License 3import math 4from typing import Iterable, cast, Union, List, Dict, Callable 5from ezdxf.lldxf import const 6from ezdxf.addons.drawing.backend import Backend 7from ezdxf.addons.drawing.properties import ( 8 RenderContext, VIEWPORT_COLOR, Properties, set_color_alpha, Filling, 9) 10from ezdxf.addons.drawing.text import simplified_text_chunks 11from ezdxf.entities import ( 12 DXFGraphic, Insert, MText, Polyline, LWPolyline, Spline, Hatch, Attrib, 13 Text, Polyface, Wipeout, AttDef, Solid, Face3d 14) 15from ezdxf.entities.dxfentity import DXFTagStorage, DXFEntity 16from ezdxf.layouts import Layout 17from ezdxf.math import Vec3, Z_AXIS 18from ezdxf.path import ( 19 Path, make_path, from_hatch_boundary_path, fast_bbox_detection, 20 winding_deconstruction, from_vertices, 21) 22from ezdxf.render import MeshBuilder, TraceBuilder 23from ezdxf import reorder 24from ezdxf.proxygraphic import ProxyGraphic 25 26__all__ = ['Frontend'] 27NEG_Z_AXIS = -Z_AXIS 28INFINITE_LINE_LENGTH = 25 29DEFAULT_PDSIZE = 1 30 31IGNORE_PROXY_GRAPHICS = 0 32USE_PROXY_GRAPHICS = 1 33PREFER_PROXY_GRAPHICS = 2 34 35 36class Frontend: 37 """ Drawing frontend, responsible for decomposing entities into graphic 38 primitives and resolving entity properties. 39 40 Args: 41 ctx: actual render context of a DXF document 42 out: backend 43 44 """ 45 46 def __init__(self, ctx: RenderContext, out: Backend, 47 proxy_graphics: int = USE_PROXY_GRAPHICS): 48 # RenderContext contains all information to resolve resources for a 49 # specific DXF document. 50 self.ctx = ctx 51 52 # DrawingBackend is the interface to the render engine 53 self.out = out 54 55 # To get proxy graphics support proxy graphics have to be loaded: 56 # Set the global option ezdxf.options.load_proxy_graphics to True. 57 # How to handle proxy graphics: 58 # 0 = ignore proxy graphics 59 # 1 = use proxy graphics if no rendering support by ezdxf exist 60 # 2 = prefer proxy graphics over ezdxf rendering 61 self.proxy_graphics = proxy_graphics 62 63 # Transfer render context info to backend: 64 ctx.update_backend_configuration(out) 65 66 # Parents entities of current entity/sub-entity 67 self.parent_stack: List[DXFGraphic] = [] 68 69 # Approximate a full circle by `n` segments, arcs have proportional 70 # less segments 71 self.circle_approximation_count = 128 72 73 # The sagitta (also known as the versine) is a line segment drawn 74 # perpendicular to a chord, between the midpoint of that chord and the 75 # arc of the circle. https://en.wikipedia.org/wiki/Circle not used yet! 76 # Could be used for all curves CIRCLE, ARC, ELLIPSE and SPLINE 77 # self.approximation_max_sagitta = 0.01 # for drawing unit = 1m, max 78 # sagitta = 1cm 79 80 # set to None to disable nested polygon detection: 81 self.nested_polygon_detection = fast_bbox_detection 82 83 self._dispatch = self._build_dispatch_table() 84 85 def _build_dispatch_table(self) -> Dict[ 86 str, Callable[[DXFGraphic, Properties], None]]: 87 dispatch_table = { 88 'POINT': self.draw_point_entity, 89 'HATCH': self.draw_hatch_entity, 90 'MESH': self.draw_mesh_entity, 91 'VIEWPORT': self.draw_viewport_entity, 92 'WIPEOUT': self.draw_wipeout_entity, 93 'MTEXT': self.draw_mtext_entity, 94 } 95 for dxftype in ('LINE', 'XLINE', 'RAY'): 96 dispatch_table[dxftype] = self.draw_line_entity 97 for dxftype in ('TEXT', 'ATTRIB', 'ATTDEF'): 98 dispatch_table[dxftype] = self.draw_text_entity 99 for dxftype in ('CIRCLE', 'ARC', 'ELLIPSE', 'SPLINE'): 100 dispatch_table[dxftype] = self.draw_curve_entity 101 for dxftype in ('3DFACE', 'SOLID', 'TRACE'): 102 dispatch_table[dxftype] = self.draw_solid_entity 103 for dxftype in ('POLYLINE', 'LWPOLYLINE'): 104 dispatch_table[dxftype] = self.draw_polyline_entity 105 106 # These types have a virtual_entities() method, which returns 107 # the content of the associated block or anonymous block 108 for dxftype in ['INSERT', 'DIMENSION', 'ARC_DIMENSION', 109 'LARGE_RADIAL_DIMENSION', 'LEADER', 110 'MLINE', 'ACAD_TABLE']: 111 dispatch_table[dxftype] = self.draw_composite_entity 112 113 return dispatch_table 114 115 def log_message(self, message: str): 116 print(message) 117 118 def skip_entity(self, entity: DXFEntity, msg: str) -> None: 119 self.log_message(f'skipped entity {str(entity)}. Reason: "{msg}"') 120 121 def override_properties(self, entity: DXFGraphic, 122 properties: Properties) -> None: 123 """ The :meth:`override_properties` filter can change the properties of 124 an entity independent from the DXF attributes. 125 126 This filter has access to the DXF attributes by the `entity` object, 127 the current render context, and the resolved properties by the 128 `properties` object. It is recommended to modify only the `properties` 129 object in this filter. 130 """ 131 if entity.dxftype() == 'HATCH': 132 properties.color = set_color_alpha(properties.color, 200) 133 134 def draw_layout(self, layout: 'Layout', finalize: bool = True) -> None: 135 self.parent_stack = [] 136 handle_mapping = list(layout.get_redraw_order()) 137 if handle_mapping: 138 self.draw_entities(reorder.ascending(layout, handle_mapping)) 139 else: 140 self.draw_entities(layout) 141 self.out.set_background(self.ctx.current_layout.background_color) 142 if finalize: 143 self.out.finalize() 144 145 def draw_entities(self, entities: Iterable[DXFGraphic]) -> None: 146 for entity in entities: 147 # Skip unsupported DXF entities - just tag storage to preserve data 148 if isinstance(entity, DXFTagStorage): 149 self.skip_entity(entity, 'Cannot parse DXF entity') 150 continue 151 152 properties = self.ctx.resolve_all(entity) 153 self.override_properties(entity, properties) 154 155 # The content of a block reference does not depend 156 # on the visibility state of the INSERT entity: 157 if properties.is_visible or entity.dxftype() == 'INSERT': 158 self.draw_entity(entity, properties) 159 elif not properties.is_visible: 160 self.skip_entity(entity, 'invisible') 161 162 def draw_entity(self, entity: DXFGraphic, properties: Properties) -> None: 163 """ Draw a single DXF entity. 164 165 Args: 166 entity: DXF Entity 167 properties: resolved entity properties 168 169 """ 170 self.out.enter_entity(entity, properties) 171 172 if entity.proxy_graphic and self.proxy_graphics == PREFER_PROXY_GRAPHICS: 173 self.draw_proxy_graphic(entity) 174 else: 175 draw_method = self._dispatch.get(entity.dxftype(), None) 176 if draw_method is not None: 177 draw_method(entity, properties) 178 elif entity.proxy_graphic and self.proxy_graphics == USE_PROXY_GRAPHICS: 179 self.draw_proxy_graphic(entity) 180 else: 181 self.skip_entity(entity, 'Unsupported entity') 182 self.out.exit_entity(entity) 183 184 def draw_line_entity(self, entity: DXFGraphic, 185 properties: Properties) -> None: 186 d, dxftype = entity.dxf, entity.dxftype() 187 if dxftype == 'LINE': 188 self.out.draw_line(d.start, d.end, properties) 189 190 elif dxftype in ('XLINE', 'RAY'): 191 start = d.start 192 delta = d.unit_vector * INFINITE_LINE_LENGTH 193 if dxftype == 'XLINE': 194 self.out.draw_line(start - delta / 2, start + delta / 2, 195 properties) 196 elif dxftype == 'RAY': 197 self.out.draw_line(start, start + delta, properties) 198 else: 199 raise TypeError(dxftype) 200 201 def draw_text_entity(self, entity: DXFGraphic, 202 properties: Properties) -> None: 203 if is_spatial_text(Vec3(entity.dxf.extrusion)): 204 self.draw_text_entity_3d(entity, properties) 205 else: 206 self.draw_text_entity_2d(entity, properties) 207 208 def draw_text_entity_2d(self, entity: DXFGraphic, 209 properties: Properties) -> None: 210 d, dxftype = entity.dxf, entity.dxftype() 211 if dxftype in ('TEXT', 'ATTRIB', 'ATTDEF'): 212 entity = cast(Union[Text, Attrib, AttDef], entity) 213 for line, transform, cap_height in simplified_text_chunks( 214 entity, self.out, font=properties.font): 215 self.out.draw_text(line, transform, properties, cap_height) 216 else: 217 raise TypeError(dxftype) 218 219 def draw_text_entity_3d(self, entity: DXFGraphic, 220 properties: Properties) -> None: 221 self.skip_entity(entity, '3D text not supported') 222 223 def draw_mtext_entity(self, mtext: 'MText', 224 properties: Properties) -> None: 225 if is_spatial_text(Vec3(mtext.dxf.extrusion)): 226 self.skip_entity(mtext, '3D MTEXT not supported') 227 return 228 if mtext.has_columns: 229 columns = mtext.columns 230 if len(columns.linked_columns): 231 has_linked_content = any(c.text for c in columns.linked_columns) 232 if has_linked_content: 233 # Column content is spread across multiple MTEXT entities. 234 # For now we trust the DXF creator that each MTEXT entity 235 # has exact the required column content. 236 # This is not granted and AutoCAD/BricsCAD do the column 237 # content distribution always by themself! 238 self.draw_mtext_column(mtext, properties) 239 for column in mtext.columns.linked_columns: 240 self.draw_mtext_column(column, properties) 241 return 242 self.distribute_mtext_columns_content(mtext, properties) 243 else: 244 self.draw_mtext_column(mtext, properties) 245 246 def distribute_mtext_columns_content(self, mtext: MText, 247 properties: Properties): 248 """ Distribute the content of the MTEXT entity across multiple columns 249 """ 250 # TODO: complex MTEXT renderer 251 self.draw_mtext_column(mtext, properties) 252 253 def draw_mtext_column(self, mtext: MText, 254 properties: Properties) -> None: 255 """ Draw the content of a MTEXT entity as a single column. """ 256 # TODO: complex MTEXT renderer 257 for line, transform, cap_height in simplified_text_chunks( 258 mtext, self.out, font=properties.font): 259 self.out.draw_text(line, transform, properties, cap_height) 260 261 def draw_curve_entity(self, entity: DXFGraphic, 262 properties: Properties) -> None: 263 try: 264 path = make_path(entity) 265 except AttributeError: # API usage error 266 raise TypeError( 267 f"Unsupported DXF type {entity.dxftype()}") 268 self.out.draw_path(path, properties) 269 270 def draw_point_entity(self, entity: DXFGraphic, 271 properties: Properties) -> None: 272 point = cast('Point', entity) 273 pdmode = self.out.pdmode 274 275 # Defpoints are regular POINT entities located at the "defpoints" layer: 276 if properties.layer.lower() == 'defpoints': 277 if not self.out.show_defpoints: 278 return 279 else: # Render defpoints as dimensionless points: 280 pdmode = 0 281 282 pdsize = self.out.pdsize 283 if pdsize <= 0: # relative points size is not supported 284 pdsize = DEFAULT_PDSIZE 285 286 if pdmode == 0: 287 self.out.draw_point(entity.dxf.location, properties) 288 else: 289 for entity in point.virtual_entities(pdsize, pdmode): 290 if entity.dxftype() == 'LINE': 291 start = Vec3(entity.dxf.start) 292 end = entity.dxf.end 293 if start.isclose(end): 294 self.out.draw_point(start, properties) 295 else: 296 self.out.draw_line(start, end, properties) 297 pass 298 else: # CIRCLE 299 self.draw_curve_entity(entity, properties) 300 301 def draw_solid_entity(self, entity: DXFGraphic, 302 properties: Properties) -> None: 303 assert isinstance(entity, (Solid, Face3d)), \ 304 "API error, requires a SOLID, TRACE or 3DFACE entity" 305 dxf, dxftype = entity.dxf, entity.dxftype() 306 points = entity.wcs_vertices() 307 if dxftype == '3DFACE': 308 self.out.draw_path(from_vertices(points, close=True), properties) 309 else: 310 # set solid fill type for SOLID and TRACE 311 properties.filling = Filling() 312 self.out.draw_filled_polygon(points, properties) 313 314 def draw_hatch_entity(self, entity: DXFGraphic, 315 properties: Properties) -> None: 316 def to_path(p): 317 path = from_hatch_boundary_path(p, ocs, elevation) 318 path.close() 319 return path 320 321 if not self.out.show_hatch: 322 return 323 324 hatch = cast(Hatch, entity) 325 ocs = hatch.ocs() 326 # all OCS coordinates have the same z-axis stored as vector (0, 0, z), 327 # default (0, 0, 0) 328 elevation = entity.dxf.elevation.z 329 330 external_paths = [] 331 holes = [] 332 paths = hatch.paths.rendering_paths(hatch.dxf.hatch_style) 333 if self.nested_polygon_detection: 334 polygons = self.nested_polygon_detection(map(to_path, paths)) 335 external_paths, holes = winding_deconstruction(polygons) 336 else: 337 for p in paths: 338 if p.path_type_flags & const.BOUNDARY_PATH_EXTERNAL: 339 external_paths.append(to_path(p)) 340 else: 341 holes.append(to_path(p)) 342 343 if external_paths: 344 self.out.draw_filled_paths(external_paths, holes, properties) 345 elif holes: 346 # First path is the exterior path, everything else is a hole 347 self.out.draw_filled_paths([holes[0]], holes[1:], properties) 348 349 def draw_wipeout_entity(self, entity: DXFGraphic, 350 properties: Properties) -> None: 351 wipeout = cast(Wipeout, entity) 352 properties.filling = Filling() 353 properties.color = self.ctx.current_layout.background_color 354 path = wipeout.boundary_path_wcs() 355 self.out.draw_filled_polygon(path, properties) 356 357 def draw_viewport_entity(self, entity: DXFGraphic, 358 properties: Properties) -> None: 359 assert entity.dxftype() == 'VIEWPORT' 360 # Special VIEWPORT id == 1, this viewport defines the "active viewport" 361 # which is the area currently shown in the layout tab by the CAD 362 # application. 363 # BricsCAD set id to -1 if the viewport is off and 'status' (group 364 # code 68) is not present. 365 if entity.dxf.id < 2 or entity.dxf.status < 1: 366 return 367 dxf = entity.dxf 368 view_vector: Vec3 = dxf.view_direction_vector 369 mag = view_vector.magnitude 370 if math.isclose(mag, 0.0): 371 self.log_message('Warning: viewport with null view vector') 372 return 373 view_vector /= mag 374 if not math.isclose(view_vector.dot(Vec3(0, 0, 1)), 1.0): 375 self.log_message( 376 f'Cannot render viewport with non-perpendicular view direction:' 377 f' {dxf.view_direction_vector}' 378 ) 379 return 380 381 cx, cy = dxf.center.x, dxf.center.y 382 dx = dxf.width / 2 383 dy = dxf.height / 2 384 minx, miny = cx - dx, cy - dy 385 maxx, maxy = cx + dx, cy + dy 386 points = [ 387 (minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy), (minx, miny) 388 ] 389 props = Properties() 390 props.color = VIEWPORT_COLOR 391 # Set default SOLID filling for VIEWPORT 392 props.filling = Filling() 393 self.out.draw_filled_polygon([Vec3(x, y, 0) for x, y in points], 394 props) 395 396 def draw_mesh_entity(self, entity: DXFGraphic, 397 properties: Properties) -> None: 398 builder = MeshBuilder.from_mesh(entity) 399 self.draw_mesh_builder_entity(builder, properties) 400 401 def draw_mesh_builder_entity(self, builder: MeshBuilder, 402 properties: Properties) -> None: 403 for face in builder.faces_as_vertices(): 404 self.out.draw_path( 405 from_vertices(face, close=True), properties=properties) 406 407 def draw_polyline_entity(self, entity: DXFGraphic, 408 properties: Properties) -> None: 409 dxftype = entity.dxftype() 410 if dxftype == 'POLYLINE': 411 e = cast(Polyface, entity) 412 if e.is_polygon_mesh or e.is_poly_face_mesh: 413 # draw 3D mesh or poly-face entity 414 self.draw_mesh_builder_entity( 415 MeshBuilder.from_polyface(e), 416 properties, 417 ) 418 return 419 420 entity = cast(Union[LWPolyline, Polyline], entity) 421 is_lwpolyline = dxftype == 'LWPOLYLINE' 422 423 if entity.has_width: # draw banded 2D polyline 424 elevation = 0.0 425 ocs = entity.ocs() 426 transform = ocs.transform 427 if transform: 428 if is_lwpolyline: # stored as float 429 elevation = entity.dxf.elevation 430 else: # stored as vector (0, 0, elevation) 431 elevation = Vec3(entity.dxf.elevation).z 432 433 trace = TraceBuilder.from_polyline( 434 entity, segments=self.circle_approximation_count // 2 435 ) 436 for polygon in trace.polygons(): # polygon is a sequence of Vec2() 437 if transform: 438 points = ocs.points_to_wcs( 439 Vec3(v.x, v.y, elevation) for v in polygon 440 ) 441 else: 442 points = Vec3.generate(polygon) 443 # Set default SOLID filling for LWPOLYLINE 444 properties.filling = Filling() 445 self.out.draw_filled_polygon(points, properties) 446 return 447 448 path = make_path(entity) 449 self.out.draw_path(path, properties) 450 451 def draw_composite_entity(self, entity: DXFGraphic, 452 properties: Properties) -> None: 453 def set_opaque(entities: Iterable[DXFGraphic]): 454 for child in entities: 455 # todo: defaults to 1.0 (fully transparent)??? 456 child.transparency = 0.0 457 yield child 458 459 def draw_insert(insert: Insert): 460 self.draw_entities(insert.attribs) 461 # draw_entities() includes the visibility check: 462 self.draw_entities(insert.virtual_entities( 463 skipped_entity_callback=self.skip_entity) 464 ) 465 466 dxftype = entity.dxftype() 467 if dxftype == 'INSERT': 468 entity = cast(Insert, entity) 469 self.ctx.push_state(properties) 470 if entity.mcount > 1: 471 for virtual_insert in entity.multi_insert(): 472 draw_insert(virtual_insert) 473 else: 474 draw_insert(entity) 475 self.ctx.pop_state() 476 477 elif hasattr(entity, 'virtual_entities'): 478 # draw_entities() includes the visibility check: 479 self.draw_entities(set_opaque(entity.virtual_entities())) 480 else: 481 raise TypeError(dxftype) 482 483 def draw_proxy_graphic(self, entity: DXFGraphic) -> None: 484 if entity.proxy_graphic: 485 gfx = ProxyGraphic(entity.proxy_graphic, entity.doc) 486 self.draw_entities(gfx.virtual_entities()) 487 488 489def is_spatial_text(extrusion: Vec3) -> bool: 490 # note: the magnitude of the extrusion vector has no effect on text scale 491 return not math.isclose(extrusion.x, 0) or not math.isclose(extrusion.y, 0) 492