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