1# Created: 28.12.2018 2# Copyright (c) 2018-2020, Manfred Moitzi 3# License: MIT License 4from typing import TYPE_CHECKING, Tuple, Iterable, List, cast 5import math 6from ezdxf.math import Vec3, Vec2, ConstructionRay, UCS 7from ezdxf.render.arrows import ARROWS, connection_point 8from ezdxf.entities.dimstyleoverride import DimStyleOverride 9 10from .dim_base import BaseDimensionRenderer, TextBox 11 12if TYPE_CHECKING: 13 from ezdxf.eztypes import Dimension, Vertex, GenericLayoutType 14 15 16def order_leader_points(p1: Vec2, p2: Vec2, p3: Vec2) -> Tuple[Vec2, Vec2]: 17 if (p1 - p2).magnitude > (p1 - p3).magnitude: 18 return p3, p2 19 else: 20 return p2, p3 21 22 23class LinearDimension(BaseDimensionRenderer): 24 """ 25 Linear dimension line renderer, used for horizontal, vertical, rotated and aligned DIMENSION entities. 26 27 Args: 28 dimension: DXF entity DIMENSION 29 ucs: user defined coordinate system 30 override: dimension style override management object 31 32 """ 33 34 def __init__(self, dimension: 'Dimension', ucs: 'UCS' = None, override: 'DimStyleOverride' = None): 35 super().__init__(dimension, ucs, override) 36 if self.text_movement_rule == 0: 37 # moves the dimension line with dimension text, this makes no sense for ezdxf (just set `base` argument) 38 self.text_movement_rule = 2 39 40 self.oblique_angle = self.dimension.get_dxf_attrib('oblique_angle', 90) # type: float 41 self.dim_line_angle = self.dimension.get_dxf_attrib('angle', 0) # type: float 42 self.dim_line_angle_rad = math.radians(self.dim_line_angle) # type: float 43 self.ext_line_angle = self.dim_line_angle + self.oblique_angle # type: float 44 self.ext_line_angle_rad = math.radians(self.ext_line_angle) # type: float 45 46 # text is aligned to dimension line 47 self.text_rotation = self.dim_line_angle # type: float 48 if self.text_halign in (3, 4): # text above extension line, is always aligned with extension lines 49 self.text_rotation = self.ext_line_angle 50 51 self.ext1_line_start = Vec2(self.dimension.dxf.defpoint2) 52 self.ext2_line_start = Vec2(self.dimension.dxf.defpoint3) 53 54 ext1_ray = ConstructionRay(self.ext1_line_start, angle=self.ext_line_angle_rad) 55 ext2_ray = ConstructionRay(self.ext2_line_start, angle=self.ext_line_angle_rad) 56 dim_line_ray = ConstructionRay(self.dimension.dxf.defpoint, angle=self.dim_line_angle_rad) 57 58 self.dim_line_start = dim_line_ray.intersect(ext1_ray) # type: Vec2 59 self.dim_line_end = dim_line_ray.intersect(ext2_ray) # type: Vec2 60 self.dim_line_center = self.dim_line_start.lerp(self.dim_line_end) # type: Vec2 61 62 if self.dim_line_start == self.dim_line_end: 63 self.dim_line_vec = Vec2.from_angle(self.dim_line_angle_rad) 64 else: 65 self.dim_line_vec = (self.dim_line_end - self.dim_line_start).normalize() # type: Vec2 66 67 # set dimension defpoint to expected location - 3D vertex required! 68 self.dimension.dxf.defpoint = Vec3(self.dim_line_start) 69 70 self.measurement = (self.dim_line_end - self.dim_line_start).magnitude # type: float 71 self.text = self.text_override(self.measurement * self.dim_measurement_factor) # type: str 72 73 # only for linear dimension in multi point mode 74 self.multi_point_mode = override.pop('multi_point_mode', False) 75 76 # 1 .. move wide text up 77 # 2 .. move wide text down 78 # None .. ignore 79 self.move_wide_text = override.pop('move_wide_text', None) # type: bool 80 81 # actual text width in drawing units 82 self.dim_text_width = 0 # type: float 83 84 # arrows 85 self.required_arrows_space = 2 * self.arrow_size + self.text_gap # type: float 86 self.arrows_outside = self.required_arrows_space > self.measurement # type: bool 87 88 # text location and rotation 89 if self.text: 90 # text width and required space 91 self.dim_text_width = self.text_width(self.text) # type: float 92 if self.dim_tolerance: 93 self.dim_text_width += self.tol_text_width 94 95 elif self.dim_limits: 96 # limits show the upper and lower limit of the measurement as stacked values 97 # and with the size of tolerances 98 measurement = self.measurement * self.dim_measurement_factor 99 self.measurement_upper_limit = measurement + self.tol_maximum 100 self.measurement_lower_limit = measurement - self.tol_minimum 101 self.tol_text_upper = self.format_tolerance_text(self.measurement_upper_limit) 102 self.tol_text_lower = self.format_tolerance_text(self.measurement_lower_limit) 103 self.tol_text_width = self.tolerance_text_width(max(len(self.tol_text_upper), len(self.tol_text_lower))) 104 105 # only limits are displayed so: 106 self.dim_text_width = self.tol_text_width 107 108 if self.multi_point_mode: 109 # ezdxf has total control about vertical text position in multi point mode 110 self.text_vertical_position = 0. 111 112 if self.text_valign == 0 and abs(self.text_vertical_position) < 0.7: 113 # vertical centered text needs also space for arrows 114 required_space = self.dim_text_width + 2 * self.arrow_size 115 else: 116 required_space = self.dim_text_width 117 self.is_wide_text = required_space > self.measurement 118 119 if not self.force_text_inside: 120 # place text outside if wide text and not forced inside 121 self.text_outside = self.is_wide_text 122 elif self.is_wide_text and self.text_halign < 3: 123 # center wide text horizontal 124 self.text_halign = 0 125 126 # use relative text shift to move wide text up or down in multi point mode 127 if self.multi_point_mode and self.is_wide_text and self.move_wide_text: 128 shift_value = self.text_height + self.text_gap 129 if self.move_wide_text == 1: # move text up 130 self.text_shift_v = shift_value 131 if self.vertical_placement == -1: # text below dimension line 132 # shift again 133 self.text_shift_v += shift_value 134 elif self.move_wide_text == 2: # move text down 135 self.text_shift_v = -shift_value 136 if self.vertical_placement == 1: # text above dimension line 137 # shift again 138 self.text_shift_v -= shift_value 139 140 # get final text location - no altering after this line 141 self.text_location = self.get_text_location() # type: Vec2 142 143 # text rotation override 144 rotation = self.text_rotation # type: float 145 if self.user_text_rotation is not None: 146 rotation = self.user_text_rotation 147 elif self.text_outside and self.text_outside_horizontal: 148 rotation = 0 149 elif self.text_inside and self.text_inside_horizontal: 150 rotation = 0 151 self.text_rotation = rotation 152 153 self.text_box = TextBox( 154 center=self.text_location, 155 width=self.dim_text_width, 156 height=self.text_height, 157 angle=self.text_rotation, 158 gap=self.text_gap * .75 159 ) 160 if self.text_has_leader: 161 p1, p2, *_ = self.text_box.corners 162 self.leader1, self.leader2 = order_leader_points(self.dim_line_center, p1, p2) 163 # not exact what BricsCAD (AutoCAD) expect, but close enough 164 self.dimension.dxf.text_midpoint = self.leader1 165 else: 166 # write final text location into DIMENSION entity 167 self.dimension.dxf.text_midpoint = self.text_location 168 169 @property 170 def has_relative_text_movement(self): 171 return bool(self.text_shift_h or self.text_shift_v) 172 173 def apply_text_shift(self, location: Vec2, text_rotation: float) -> Vec2: 174 """ 175 Add `self.text_shift_h` and `sel.text_shift_v` to point `location`, shifting along and perpendicular to 176 text orientation defined by `text_rotation` 177 178 Args: 179 location: location point 180 text_rotation: text rotation in degrees 181 182 Returns: new location 183 184 """ 185 shift_vec = Vec2((self.text_shift_h, self.text_shift_v)) 186 location += shift_vec.rotate(text_rotation) 187 return location 188 189 def render(self, block: 'GenericLayoutType') -> None: 190 """ 191 Main method to create dimension geometry of basic DXF entities in the associated BLOCK layout. 192 193 Args: 194 block: target BLOCK for rendering 195 196 """ 197 # call required to setup some requirements 198 super().render(block) 199 200 # add extension line 1 201 if not self.suppress_ext1_line: 202 above_ext_line1 = self.text_halign == 3 203 start, end = self.extension_line_points(self.ext1_line_start, self.dim_line_start, above_ext_line1) 204 self.add_extension_line(start, end, linetype=self.ext1_linetype_name) 205 206 # add extension line 2 207 if not self.suppress_ext2_line: 208 above_ext_line2 = self.text_halign == 4 209 start, end = self.extension_line_points(self.ext2_line_start, self.dim_line_end, above_ext_line2) 210 self.add_extension_line(start, end, linetype=self.ext2_linetype_name) 211 212 # add arrow symbols (block references), also adjust dimension line start and end point 213 dim_line_start, dim_line_end = self.add_arrows() 214 215 # add dimension line 216 self.add_dimension_line(dim_line_start, dim_line_end) 217 218 # add measurement text as last entity to see text fill properly 219 if self.text: 220 if self.supports_dxf_r2000: 221 text = self.compile_mtext() 222 else: 223 text = self.text 224 self.add_measurement_text(text, self.text_location, self.text_rotation) 225 if self.text_has_leader: 226 self.add_leader(self.dim_line_center, self.leader1, self.leader2) 227 228 # add POINT entities at definition points 229 self.add_defpoints([self.dim_line_start, self.ext1_line_start, self.ext2_line_start]) 230 231 def get_text_location(self) -> Vec2: 232 """ 233 Get text midpoint in UCS from user defined location or default text location. 234 235 """ 236 # apply relative text shift as user location override without leader 237 if self.has_relative_text_movement: 238 location = self.default_text_location() 239 location = self.apply_text_shift(location, self.text_rotation) 240 self.location_override(location) 241 242 if self.user_location is not None: 243 location = self.user_location 244 if self.relative_user_location: 245 location = self.dim_line_center + location 246 # define overridden text location as outside 247 self.text_outside = True 248 else: 249 location = self.default_text_location() 250 251 return location 252 253 def default_text_location(self) -> Vec2: 254 """ 255 Calculate default text location in UCS based on `self.text_halign`, `self.text_valign` and `self.text_outside` 256 257 """ 258 start = self.dim_line_start 259 end = self.dim_line_end 260 halign = self.text_halign 261 # positions the text above and aligned with the first/second extension line 262 if halign in (3, 4): 263 # horizontal location 264 hdist = self.text_gap + self.text_height / 2. 265 hvec = self.dim_line_vec * hdist 266 location = (start if halign == 3 else end) - hvec 267 # vertical location 268 vdist = self.ext_line_extension + self.dim_text_width / 2. 269 location += Vec2.from_deg_angle(self.ext_line_angle).normalize(vdist) 270 else: 271 # relocate outside text to center location 272 if self.text_outside: 273 halign = 0 274 275 if halign == 0: 276 location = self.dim_line_center # center of dimension line 277 else: 278 hdist = self.dim_text_width / 2. + self.arrow_size + self.text_gap 279 if halign == 1: # positions the text next to the first extension line 280 location = start + (self.dim_line_vec * hdist) 281 else: # positions the text next to the second extension line 282 location = end - (self.dim_line_vec * hdist) 283 284 if self.text_outside: # move text up 285 vdist = self.ext_line_extension + self.text_gap + self.text_height / 2. 286 else: 287 # distance from extension line to text midpoint 288 vdist = self.text_vertical_distance() 289 location += self.dim_line_vec.orthogonal().normalize(vdist) 290 291 return location 292 293 def add_arrows(self) -> Tuple[Vec2, Vec2]: 294 """ 295 Add arrows or ticks to dimension. 296 297 Returns: dimension line connection points 298 299 """ 300 attribs = { 301 'color': self.dim_line_color, 302 } 303 start = self.dim_line_start 304 end = self.dim_line_end 305 outside = self.arrows_outside 306 arrow1 = not self.suppress_arrow1 307 arrow2 = not self.suppress_arrow2 308 if self.tick_size > 0.: # oblique stroke, but double the size 309 if arrow1: 310 self.add_blockref( 311 ARROWS.oblique, 312 insert=start, 313 rotation=self.dim_line_angle, 314 scale=self.tick_size * 2, 315 dxfattribs=attribs, 316 ) 317 if arrow2: 318 self.add_blockref( 319 ARROWS.oblique, 320 insert=end, 321 rotation=self.dim_line_angle, 322 scale=self.tick_size * 2, 323 dxfattribs=attribs, 324 ) 325 else: 326 scale = self.arrow_size 327 start_angle = self.dim_line_angle + 180. 328 end_angle = self.dim_line_angle 329 if outside: 330 start_angle, end_angle = end_angle, start_angle 331 332 if arrow1: 333 self.add_blockref(self.arrow1_name, insert=start, scale=scale, rotation=start_angle, 334 dxfattribs=attribs) # reverse 335 if arrow2: 336 self.add_blockref(self.arrow2_name, insert=end, scale=scale, rotation=end_angle, dxfattribs=attribs) 337 338 if not outside: 339 # arrows inside extension lines: adjust connection points for the remaining dimension line 340 if arrow1: 341 start = connection_point(self.arrow1_name, start, scale, start_angle) 342 if arrow2: 343 end = connection_point(self.arrow2_name, end, scale, end_angle) 344 else: 345 # add additional extension lines to arrows placed outside of dimension extension lines 346 self.add_arrow_extension_lines() 347 return start, end 348 349 def add_arrow_extension_lines(self): 350 """ 351 Add extension lines to arrows placed outside of dimension extension lines. Called by `self.add_arrows()`. 352 353 """ 354 355 def has_arrow_extension(name: str) -> bool: 356 return (name is not None) and (name in ARROWS) and (name not in ARROWS.ORIGIN_ZERO) 357 358 attribs = { 359 'color': self.dim_line_color, 360 } 361 start = self.dim_line_start 362 end = self.dim_line_end 363 arrow_size = self.arrow_size 364 365 if not self.suppress_arrow1 and has_arrow_extension(self.arrow1_name): 366 self.add_line( 367 start - self.dim_line_vec * arrow_size, 368 start - self.dim_line_vec * (2 * arrow_size), 369 dxfattribs=attribs, 370 ) 371 372 if not self.suppress_arrow2 and has_arrow_extension(self.arrow2_name): 373 self.add_line( 374 end + self.dim_line_vec * arrow_size, 375 end + self.dim_line_vec * (2 * arrow_size), 376 dxfattribs=attribs, 377 ) 378 379 def add_measurement_text(self, dim_text: str, pos: Vec2, rotation: float) -> None: 380 """ 381 Add measurement text to dimension BLOCK. 382 383 Args: 384 dim_text: dimension text 385 pos: text location 386 rotation: text rotation in degrees 387 388 """ 389 attribs = { 390 'color': self.text_color, 391 } 392 self.add_text(dim_text, pos=Vec3(pos), rotation=rotation, dxfattribs=attribs) 393 394 def add_dimension_line(self, start: 'Vertex', end: 'Vertex') -> None: 395 """ 396 Add dimension line to dimension BLOCK, adds extension DIMDLE if required, and uses DIMSD1 or DIMSD2 to suppress 397 first or second part of dimension line. Removes line parts hidden by dimension text. 398 399 Args: 400 start: dimension line start 401 end: dimension line end 402 403 """ 404 extension = self.dim_line_vec * self.dim_line_extension 405 if self.arrow1_name is None or ARROWS.has_extension_line(self.arrow1_name): 406 start = start - extension 407 if self.arrow2_name is None or ARROWS.has_extension_line(self.arrow2_name): 408 end = end + extension 409 410 attribs = self.dim_line_attributes() 411 412 if self.suppress_dim1_line or self.suppress_dim2_line: 413 # TODO: results not as expected, but good enough 414 # center should take into account text location 415 center = start.lerp(end) 416 if not self.suppress_dim1_line: 417 self.add_line(start, center, dxfattribs=attribs, remove_hidden_lines=True) 418 if not self.suppress_dim2_line: 419 self.add_line(center, end, dxfattribs=attribs, remove_hidden_lines=True) 420 else: 421 self.add_line(start, end, dxfattribs=attribs, remove_hidden_lines=True) 422 423 def extension_line_points(self, start: Vec2, end: Vec2, text_above_extline=False) -> Tuple[Vec2, Vec2]: 424 """ 425 Adjust start and end point of extension line by dimension variables DIMEXE, DIMEXO, DIMEXFIX, DIMEXLEN. 426 427 Args: 428 start: start point of extension line (measurement point) 429 end: end point at dimension line 430 text_above_extline: True if text is above and aligned with extension line 431 432 Returns: adjusted start and end point 433 434 """ 435 if start == end: 436 direction = Vec2.from_deg_angle(self.ext_line_angle) 437 else: 438 direction = (end - start).normalize() 439 if self.ext_line_fixed: 440 start = end - (direction * self.ext_line_length) 441 else: 442 start = start + direction * self.ext_line_offset 443 extension = self.ext_line_extension 444 if text_above_extline: 445 extension += self.dim_text_width 446 end = end + direction * extension 447 return start, end 448 449 def add_extension_line(self, start: 'Vertex', end: 'Vertex', linetype: str = None) -> None: 450 """ 451 Add extension lines from dimension line to measurement point. 452 453 """ 454 attribs = { 455 'color': self.ext_line_color 456 } 457 if linetype is not None: 458 attribs['linetype'] = linetype 459 460 # lineweight requires DXF R2000 or later 461 if self.supports_dxf_r2000: 462 attribs['lineweight'] = self.ext_lineweight 463 464 self.add_line(start, end, dxfattribs=attribs) 465 466 def transform_ucs_to_wcs(self) -> None: 467 """ 468 Transforms dimension definition points into WCS or if required into OCS. 469 470 Can not be called in __init__(), because inherited classes may be need unmodified values. 471 472 """ 473 474 def from_ucs(attr, func): 475 point = self.dimension.get_dxf_attrib(attr) 476 self.dimension.set_dxf_attrib(attr, func(point)) 477 478 from_ucs('defpoint', self.ucs.to_wcs) 479 from_ucs('defpoint2', self.ucs.to_wcs) 480 from_ucs('defpoint3', self.ucs.to_wcs) 481 from_ucs('text_midpoint', self.ucs.to_ocs) 482 self.dimension.dxf.angle = self.ucs.to_ocs_angle_deg(self.dimension.dxf.angle) 483 484 if self.requires_extrusion: 485 self.dimension.dxf.extrusion = self.ucs.uz 486 487 488CAN_SUPPRESS_ARROW1 = { 489 ARROWS.dot, 490 ARROWS.dot_small, 491 ARROWS.dot_blank, 492 ARROWS.origin_indicator, 493 ARROWS.origin_indicator_2, 494 ARROWS.dot_smallblank, 495 ARROWS.none, 496 ARROWS.oblique, 497 ARROWS.box_filled, 498 ARROWS.box, 499 ARROWS.integral, 500 ARROWS.architectural_tick, 501} 502 503 504def sort_projected_points(points: Iterable['Vertex'], angle: float = 0) -> List[Vec2]: 505 direction = Vec2.from_deg_angle(angle) 506 projected_vectors = [(direction.project(Vec2(p)), p) for p in points] 507 return [p for projection, p in sorted(projected_vectors)] 508 509 510def multi_point_linear_dimension( 511 layout: 'GenericLayoutType', 512 base: 'Vertex', 513 points: Iterable['Vertex'], 514 angle: float = 0, 515 ucs: 'UCS' = None, 516 avoid_double_rendering: bool = True, 517 dimstyle: str = 'EZDXF', 518 override: dict = None, 519 dxfattribs: dict = None, 520 discard=False) -> None: 521 """ 522 Creates multiple DIMENSION entities for each point pair in `points`. Measurement points will be sorted by appearance 523 on the dimension line vector. 524 525 Args: 526 layout: target layout (model space, paper space or block) 527 base: base point, any point on the dimension line vector will do 528 points: iterable of measurement points 529 angle: dimension line rotation in degrees (0=horizontal, 90=vertical) 530 ucs: user defined coordinate system 531 avoid_double_rendering: removes first extension line and arrow of following DIMENSION entity 532 dimstyle: dimension style name 533 override: dictionary of overridden dimension style attributes 534 dxfattribs: DXF attributes for DIMENSION entities 535 discard: discard rendering result for friendly CAD applications like BricsCAD to get a native and likely better 536 rendering result. (does not work with AutoCAD) 537 538 """ 539 540 def suppress_arrow1(dimstyle_override) -> bool: 541 arrow_name1, arrow_name2 = dimstyle_override.get_arrow_names() 542 if (arrow_name1 is None) or (arrow_name1 in CAN_SUPPRESS_ARROW1): 543 return True 544 else: 545 return False 546 547 points = sort_projected_points(points, angle) 548 base = Vec2(base) 549 override = override or {} 550 override['dimtix'] = 1 # do not place measurement text outside 551 override['dimtvp'] = 0 # do not place measurement text outside 552 override['multi_point_mode'] = True 553 # 1 .. move wide text up; 2 .. move wide text down; None .. ignore 554 # moving text down, looks best combined with text fill bg: DIMTFILL = 1 555 move_wide_text = 1 556 _suppress_arrow1 = False 557 first_run = True 558 559 for p1, p2 in zip(points[:-1], points[1:]): 560 _override = dict(override) 561 _override['move_wide_text'] = move_wide_text 562 if avoid_double_rendering and not first_run: 563 _override['dimse1'] = 1 564 _override['suppress_arrow1'] = _suppress_arrow1 565 566 style = layout.add_linear_dim( 567 Vec3(base), 568 Vec3(p1), 569 Vec3(p2), 570 angle=angle, 571 dimstyle=dimstyle, 572 override=_override, 573 dxfattribs=dxfattribs, 574 ) 575 if first_run: 576 _suppress_arrow1 = suppress_arrow1(style) 577 578 renderer = cast(LinearDimension, style.render(ucs, discard=discard)) 579 if renderer.is_wide_text: 580 # after wide text switch moving direction 581 if move_wide_text == 1: 582 move_wide_text = 2 583 else: 584 move_wide_text = 1 585 else: # reset to move text up 586 move_wide_text = 1 587 first_run = False 588