# Copyright (c) 2018-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
    TYPE_CHECKING,
    Tuple,
    Iterable,
    Optional,
    Dict,
    Any,
    List,
    cast,
)
import math
import abc
from ezdxf.math import (
    Vec3,
    Vec2,
    UVec,
    ConstructionLine,
    ConstructionBox,
    ConstructionArc,
)
from ezdxf.math import UCS, PassTroughUCS, xround, Z_AXIS
from ezdxf.lldxf import const
from ezdxf.enums import TextEntityAlignment
from ezdxf._options import options
from ezdxf.lldxf.const import DXFValueError, DXFUndefinedBlockError
from ezdxf.tools import suppress_zeros
from ezdxf.render.arrows import ARROWS
from ezdxf.entities import DimStyleOverride, Dimension

if TYPE_CHECKING:
    from ezdxf.eztypes import (
        Drawing,
        GenericLayoutType,
        Textstyle,
    )


class TextBox(ConstructionBox):
    """Text boundaries representation."""

    def __init__(
        self,
        center: Vec2 = Vec2(0, 0),
        width: float = 0.0,
        height: float = 0.0,
        angle: float = 0.0,
        hgap: float = 0.0,  # horizontal gap - width
        vgap: float = 0.0,  # vertical gap - height
    ):
        super().__init__(
            center, width + 2.0 * hgap, height + 2.0 * vgap, angle
        )


PLUS_MINUS = "±"
_TOLERANCE_COMMON = r"\A{align};{txt}{{\H{fac:.2f}x;"
TOLERANCE_TEMPLATE1 = _TOLERANCE_COMMON + r"{tol}}}"
TOLERANCE_TEMPLATE2 = _TOLERANCE_COMMON + r"\S{upr}^ {lwr};}}"
LIMITS_TEMPLATE = r"{{\H{fac:.2f}x;\S{upr}^ {lwr};}}"


def OptionalVec2(v) -> Optional[Vec2]:
    if v is not None:
        return Vec2(v)
    else:
        return None


def sign_char(value: float) -> str:
    if value < 0.0:
        return "-"
    elif value > 0:
        return "+"
    else:
        return " "


def format_text(
    value: float,
    dimrnd: float = None,
    dimdec: int = 2,
    dimzin: int = 0,
    dimdsep: str = ".",
) -> str:
    if dimrnd is not None:
        value = xround(value, dimrnd)

    if dimdec is None:
        fmt = "{:f}"
        # Remove pending zeros for undefined decimal places:
        # '{:f}'.format(0) -> '0.000000'
        dimzin = dimzin | 8
    else:
        fmt = "{:." + str(dimdec) + "f}"
    text = fmt.format(value)

    leading = bool(dimzin & 4)
    pending = bool(dimzin & 8)
    text = suppress_zeros(text, leading, pending)
    if dimdsep != ".":
        text = text.replace(".", dimdsep)
    return text


def apply_dimpost(text: str, dimpost: str) -> str:
    if "<>" in dimpost:
        fmt = dimpost.replace("<>", "{}", 1)
        return fmt.format(text)
    else:
        raise DXFValueError(f'Invalid dimpost string: "{dimpost}"')


class Tolerance:  # and Limits
    def __init__(
        self,
        dim_style: DimStyleOverride,
        cap_height: float = 1.0,
        width_factor: float = 1.0,
        dim_scale: float = 1.0,
    ):
        self.text_width_factor = width_factor
        self.dim_scale = dim_scale
        get = dim_style.get
        # Appends tolerances to dimension text.
        # enabling DIMTOL disables DIMLIM.
        self.has_tolerance = bool(get("dimtol", 0))
        self.has_limits = False
        if not self.has_tolerance:
            # Limits generates dimension limits as the default text.
            self.has_limits = bool(get("dimlim", 0))

        # Scale factor for the text height of fractions and tolerance values
        # relative to the dimension text height
        self.text_scale_factor: float = get("dimtfac", 0.5)

        self.text_decimal_separator = dim_style.get_decimal_separator()

        # Default MTEXT line spacing for tolerances (BricsCAD)
        self.line_spacing: float = 1.35

        # Sets the minimum (or lower) tolerance limit for dimension text when
        # DIMTOL or DIMLIM is on.
        # DIMTM accepts signed values.
        # If DIMTOL is on and DIMTP and DIMTM are set to the same value, a
        # tolerance value is drawn.
        # If DIMTM and DIMTP values differ, the upper tolerance is drawn above
        # the lower, and a plus sign is added to the DIMTP value if it is
        # positive.
        # For DIMTM, the program uses the negative of the value you enter
        # (adding a minus sign if you specify a positive number and a plus sign
        # if you specify a negative number).
        self.minimum: float = get("dimtm", 0.0)

        # Sets the maximum (or upper) tolerance limit for dimension text when
        # DIMTOL or DIMLIM is on.
        # DIMTP accepts signed values.
        # If DIMTOL is on and DIMTP and DIMTM are set to the same value, a
        # tolerance value is drawn.
        # If DIMTM and DIMTP values differ, the upper tolerance is drawn above
        # the lower and a plus sign is added to the DIMTP value if it is
        # positive.
        self.maximum: float = get("dimtp", 0.0)

        # Number of decimal places to display in tolerance values
        # Same value for linear and angular measurements!
        self.decimal_places: int = get("dimtdec", 2)

        # Vertical justification for tolerance values relative to the nominal dimension text
        # 0 = Bottom
        # 1 = Middle
        # 2 = Top
        self.valign: int = get("dimtolj", 0)

        # Same as DIMZIN for tolerances (self.text_suppress_zeros)
        # Same value for linear and angular measurements!
        self.suppress_zeros: int = get("dimtzin", 0)
        self.text: str = ""
        self.text_height: float = 0.0
        self.text_width: float = 0.0
        self.text_upper: str = ""
        self.text_lower: str = ""
        self.char_height: float = (
            cap_height * self.text_scale_factor * self.dim_scale
        )
        if self.has_tolerance:
            self.init_tolerance()
        elif self.has_limits:
            self.init_limits()

    @property
    def enabled(self) -> bool:
        return self.has_tolerance or self.has_limits

    def disable(self):
        self.has_tolerance = False
        self.has_limits = False

    def init_tolerance(self):
        # The tolerance values are stored in the dimension style, they are
        # independent from the actual measurement:
        # Single tolerance value +/- value
        if self.minimum == self.maximum:
            self.text_height = self.char_height
            self.text_width = self.get_text_width(self.text, self.text)
        else:  # 2 stacked values: +upper tolerance <above> -lower tolerance
            # requires 2 text lines
            self.text_height = self.char_height + (
                self.text_height * self.line_spacing
            )
            self.text_width = self.get_text_width(
                self.text_upper, self.text_lower
            )
        self.update_tolerance_text(self.maximum, self.minimum)

    def update_tolerance_text(self, tol_upper: float, tol_lower: float):
        if tol_upper == tol_lower:
            self.text = PLUS_MINUS + self.format_text(abs(tol_upper))
        else:
            self.text_upper = sign_char(tol_upper) + self.format_text(
                abs(tol_upper)
            )
            self.text_lower = sign_char(tol_lower * -1) + self.format_text(
                abs(tol_lower)
            )

    def init_limits(self):
        # self.text is always an empty string (default value)
        # Limit text are always 2 stacked numbers and requires the actual
        # measurement!
        self.text_height = self.char_height + (
            self.text_height * self.line_spacing
        )

    def format_text(self, value: float) -> str:
        """Rounding and text formatting of tolerance `value`, removes leading
        and trailing zeros if necessary.

        """
        # dimpost is not applied to limits or tolerances!
        return format_text(
            value=value,
            dimrnd=None,
            dimdec=self.decimal_places,
            dimzin=self.suppress_zeros,
            dimdsep=self.text_decimal_separator,
        )

    def get_text_width(self, upr: str, lwr: str) -> float:
        """Returns the text width of the tolerance (upr/lwr) in drawing units."""
        # todo: use matplotlib support
        count = max(len(upr), len(lwr))
        return self.text_height * self.text_width_factor * count

    def compile_mtext(self, text: str) -> str:
        if self.has_tolerance:
            align = max(int(self.valign), 0)
            align = min(align, 2)
            if not self.text:
                text = TOLERANCE_TEMPLATE2.format(
                    align=align,
                    txt=text,
                    fac=self.text_scale_factor,
                    upr=self.text_upper,
                    lwr=self.text_lower,
                )
            else:
                text = TOLERANCE_TEMPLATE1.format(
                    align=align,
                    txt=text,
                    fac=self.text_scale_factor,
                    tol=self.text,
                )
        elif self.has_limits:
            text = LIMITS_TEMPLATE.format(
                upr=self.text_upper,
                lwr=self.text_lower,
                fac=self.text_scale_factor,
            )
        return text

    def update_limits(self, measurement: float) -> None:
        upper_limit = measurement + self.maximum
        lower_limit = measurement - self.minimum
        self.text_upper = self.format_text(upper_limit)
        self.text_lower = self.format_text(lower_limit)
        self.text_width = self.get_text_width(self.text_upper, self.text_lower)


class ExtensionLines:
    default_lineweight: int = const.LINEWEIGHT_BYBLOCK

    def __init__(
        self, dim_style: DimStyleOverride, default_color: int, scale: float
    ):
        get = dim_style.get
        self.color: int = get("dimclre", default_color)  # ACI
        self.linetype1: str = get("dimltex1", "")
        self.linetype2: str = get("dimltex2", "")
        self.lineweight: int = get("dimlwe", self.default_lineweight)
        self.suppress1: bool = bool(get("dimse1", 0))
        self.suppress2: bool = bool(get("dimse2", 0))

        # Extension of extension line above the dimension line, in extension
        # line direction in most cases perpendicular to dimension line
        # (oblique!)
        self.extension_above: float = get("dimexe", 0.0) * scale

        # Distance of extension line from the measurement point in extension
        # line direction
        self.offset: float = get("dimexo", 0.0) * scale

        # Fixed length extension line, length above dimension line is still
        # self.ext_line_extension
        self.has_fixed_length: bool = bool(get("dimfxlon", 0))

        # Length below the dimension line:
        self.length_below: float = get("dimfxl", self.extension_above) * scale

    def dxfattribs(self, num: int = 1) -> Dict[str, Any]:
        """Returns default dimension line DXF attributes as dict."""
        attribs: Dict[str, Any] = {"color": self.color}
        if num == 1:
            linetype = self.linetype1
        elif num == 2:
            linetype = self.linetype2
        else:
            raise ValueError(f"invalid argument num:{num}")

        if linetype:
            attribs["linetype"] = linetype
        if self.lineweight != self.default_lineweight:
            attribs["lineweight"] = self.lineweight
        return attribs


class DimensionLine:
    default_lineweight: int = const.LINEWEIGHT_BYBLOCK

    def __init__(
        self, dim_style: DimStyleOverride, default_color: int, scale: float
    ):
        get = dim_style.get
        self.color: int = get("dimclrd", default_color)  # ACI

        # Dimension line extension, along the dimension line direction ('left'
        # and 'right')
        self.extension: float = get("dimdle", 0.0) * scale
        self.linetype: str = get("dimltype", "")
        self.lineweight: int = get("dimlwd", self.default_lineweight)

        # Suppress first part of the dimension line
        self.suppress1: bool = bool(get("dimsd1", 0))

        # Suppress second part of the dimension line
        self.suppress2: bool = bool(get("dimsd2", 0))

        # Controls whether a dimension line is drawn between the extension lines
        # even when the text is placed outside.
        # For radius and diameter dimensions (when DIMTIX is off), draws a
        # dimension line inside the circle or arc and places the text,
        # arrowheads, and leader outside.
        # 0 = no dimension line
        # 1 = draw dimension line
        # not supported yet - ezdxf behaves like option 1
        self.has_dim_line_if_text_outside: bool = bool(get("dimtofl", 1))

    def dxfattribs(self) -> Dict[str, Any]:
        """Returns default dimension line DXF attributes as dict."""
        attribs: Dict[str, Any] = {"color": self.color}
        if self.linetype:
            attribs["linetype"] = self.linetype
        if self.lineweight != self.default_lineweight:
            attribs["lineweight"] = self.lineweight
        return attribs


class Arrows:
    def __init__(self, dim_style: DimStyleOverride, color: int, scale: float):
        get = dim_style.get
        self.color: int = color
        self.tick_size: float = get("dimtsz", 0.0) * scale
        self.arrow1_name: str = ""  # empty string is a closed filled arrow
        self.arrow2_name: str = ""  # empty string is a closed filled arrow
        self.arrow_size: float = get("dimasz", 0.25) * scale
        self.suppress1 = False  # ezdxf only
        self.suppress2 = False  # ezdxf only

        if self.tick_size > 0.0:
            # Use oblique strokes as 'arrows', disables usual 'arrows' and user
            # defined blocks tick size is per definition double the size of
            # arrow size adjust arrow size to reuse the 'oblique' arrow block
            self.arrow_size = self.tick_size * 2.0
        else:
            # Arrow name or block name if user defined arrow
            (
                self.arrow1_name,
                self.arrow2_name,
            ) = dim_style.get_arrow_names()

    @property
    def has_ticks(self) -> bool:
        return self.tick_size > 0.0

    def dxfattribs(self) -> Dict[str, Any]:
        return {"color": self.color}


class Measurement:
    def __init__(
        self,
        dim_style: DimStyleOverride,
        color: int,
        scale: float,
    ):
        # update this values in method Measurement.update()
        # raw measured value
        self.raw_value: float = 0.0
        # scaled measured value
        self.value: float = 0.0
        # Final formatted dimension text
        self.text: str = ""

        dimension = dim_style.dimension
        doc = dimension.doc
        assert doc is not None, "valid DXF document required"

        # ezdxf specific attributes beyond DXF reference, therefore not stored
        # in the DXF file (DSTYLE).
        # Some of these are just an rendering effect, which will be ignored by
        # CAD applications if they modify the DIMENSION entity

        # User location override as UCS coordinates, stored as text_midpoint in
        # the DIMENSION entity
        self.user_location: Optional[Vec2] = OptionalVec2(
            dim_style.pop("user_location", None)
        )

        # User location override relative to dimline center if True
        self.relative_user_location: bool = dim_style.pop(
            "relative_user_location", False
        )

        # Shift text away from default text location - implemented as user
        # location override without leader
        # Shift text along in text direction:
        self.text_shift_h: float = dim_style.pop("text_shift_h", 0.0)
        # Shift text perpendicular to text direction:
        self.text_shift_v: float = dim_style.pop("text_shift_v", 0.0)
        # End of ezdxf specific attributes

        get = dim_style.get
        # ezdxf locates attachment points always in the text center.
        # Fixed predefined value for ezdxf rendering:
        self.text_attachment_point: int = 5

        # Ignored by ezdxf:
        self.horizontal_direction: Optional[float] = dimension.get_dxf_attrib(
            "horizontal_direction", None
        )

        # Dimension measurement factor:
        self.measurement_factor: float = get("dimlfac", 1.0)

        # Text style
        style_name: str = get("dimtxsty", options.default_dimension_text_style)
        if style_name not in doc.tables.styles:
            style_name = "Standard"
        self.text_style_name: str = style_name
        text_style = get_text_style(doc, style_name)
        self.text_height: float = get_char_height(dim_style, text_style) * scale
        self.text_width_factor: float = text_style.get_dxf_attrib("width", 1.0)
        self.stored_dim_text = dimension.dxf.text

        # text_gap: gap between dimension line an dimension text
        self.text_gap: float = get("dimgap", 0.625) * scale

        # User defined text rotation - overrides everything:
        self.user_text_rotation: float = dimension.get_dxf_attrib(
            "text_rotation", None
        )
        # calculated text rotation
        self.text_rotation: float = self.user_text_rotation
        self.text_color: int = get("dimclrt", color)  # ACI
        self.text_round: Optional[float] = get("dimrnd", None)
        self.decimal_places: int = get("dimdec", 2)
        self.angular_decimal_places: int = get("dimadec", 2)

        # Controls the suppression of zeros in the primary unit value.
        # Values 0-3 affect feet-and-inch dimensions only and are not supported
        # 4 (Bit 3) = Suppresses leading zeros in decimal dimensions,
        #   e.g. 0.5000 becomes .5000
        # 8 (Bit 4) = Suppresses trailing zeros in decimal dimensions,
        #   e.g. 12.5000 becomes 12.5
        # 12 (Bit 3+4) = Suppresses both leading and trailing zeros,
        #   e.g. 0.5000 becomes .5)
        self.suppress_zeros: int = get("dimzin", 8)

        # special setting for angular dimensions  (dimzin << 2) & 3
        # 0 = Displays all leading and trailing zeros
        # 1 = Suppresses leading zeros (for example, 0.5000 becomes .5000)
        # 2 = Suppresses trailing zeros (for example, 12.5000 becomes 12.5)
        # 3 = Suppresses leading and trailing zeros (for example, 0.5000 becomes .5)
        self.angular_suppress_zeros: int = get("dimazin", 2)

        # decimal separator char, default is ",":
        self.decimal_separator: str = dim_style.get_decimal_separator()

        self.text_post_process_format: str = get("dimpost", "")
        # text_fill:
        # 0 = None
        # 1 = Background
        # 2 = DIMTFILLCLR
        self.text_fill: int = get("dimtfill", 0)
        self.text_fill_color: int = get("dimtfillclr", 1)  # ACI
        self.text_box_fill_scale: float = 1.1

        # text_halign:
        # 0 = center
        # 1 = left
        # 2 = right
        # 3 = above ext1
        # 4 = above ext2
        self.text_halign: int = get("dimjust", 0)

        # text_valign:
        # 0 = center
        # 1 = above
        # 2 = farthest away?
        # 3 = JIS;
        # 4 = below
        # Options 2, 3 are ignored by ezdxf
        self.text_valign: int = get("dimtad", 0)

        # Controls the vertical position of dimension text above or below the
        # dimension line, when DIMTAD = 0.
        # The magnitude of the vertical offset of text is the product of the
        # text height (+gap?) and DIMTVP.
        # Setting DIMTVP to 1.0 is equivalent to setting DIMTAD = 1.
        self.text_vertical_position: float = get("dimtvp", 0.0)

        # Move text freely:
        # 0 = Moves the dimension line with dimension text
        # 1 = Adds a leader when dimension text is moved
        # 2 = Allows text to be moved freely without a leader
        self.text_movement_rule: int = get("dimtmove", 2)

        self.has_leader: bool = (
            self.user_location is not None and self.text_movement_rule == 1
        )

        # text_rotation is 0 if dimension text is 'inside', ezdxf defines
        # 'inside' as at the default text location:
        self.text_inside_horizontal: bool = get("dimtih", 0)

        # text_rotation is 0 if dimension text is 'outside', ezdxf defines
        # 'outside' as NOT at the default text location:
        self.text_outside_horizontal: bool = get("dimtoh", 0)

        # Force text location 'inside', even if the text should be moved
        # 'outside':
        self.force_text_inside: bool = bool(get("dimtix", 0))

        # How dimension text and arrows are arranged when space is not
        # sufficient to place both 'inside':
        # 0 = Places both text and arrows outside extension lines
        # 1 = Moves arrows first, then text
        # 2 = Moves text first, then arrows
        # 3 = Moves either text or arrows, whichever fits best
        # not supported - ezdxf behaves like 2
        self.text_fitting_rule: int = get("dimatfit", 2)

        # Units for all dimension types except Angular.
        # 1 = Scientific
        # 2 = Decimal
        # 3 = Engineering
        # 4 = Architectural (always displayed stacked)
        # 5 = Fractional (always displayed stacked)
        # not supported - ezdxf behaves like 2
        self.length_unit: int = get("dimlunit", 2)

        # Fraction format when DIMLUNIT is set to 4 (Architectural) or
        # 5 (Fractional).
        # 0 = Horizontal stacking
        # 1 = Diagonal stacking
        # 2 = Not stacked (for example, 1/2)
        self.fraction_format: int = get("dimfrac", 0)  # not supported

        # Units format for angular dimensions
        # 0 = Decimal degrees
        # 1 = Degrees/minutes/seconds
        # 2 = Grad
        # 3 = Radians
        self.angle_units: int = get("dimaunit", 0)

        self.has_arc_length_prefix: bool = False
        if get("dimarcsym", 2) == 0:
            self.has_arc_length_prefix = True

        # Text_outside is only True if really placed outside of default text
        # location
        # remark: user defined text location is always outside per definition
        # (not by real location)
        self.text_is_outside: bool = False

        # Final calculated or overridden dimension text location
        self.text_location: Vec2 = Vec2(0, 0)

        # True if dimension text doesn't fit between extension lines
        self.is_wide_text: bool = False

        # Text rotation was corrected to make upside down text better readable
        self.has_upside_down_correction: bool = False

    @property
    def text_is_inside(self):
        return not self.text_is_outside

    @property
    def has_relative_text_movement(self):
        return bool(self.text_shift_h or self.text_shift_v)

    def apply_text_shift(self, location: Vec2, text_rotation: float) -> Vec2:
        """Add `self.text_shift_h` and `sel.text_shift_v` to point `location`,
        shifting along and perpendicular to text orientation defined by
        `text_rotation`.

        Args:
            location: location point
            text_rotation: text rotation in degrees

        Returns: new location

        """
        shift_vec = Vec2((self.text_shift_h, self.text_shift_v))
        location += shift_vec.rotate_deg(text_rotation)
        return location

    @property
    def vertical_placement(self) -> float:
        """Returns vertical placement of dimension text as 1 for above, 0 for
        center and -1 for below dimension line.

        """
        if self.text_valign == 0:
            return 0
        elif self.text_valign == 4:
            return -1
        else:
            return 1

    def text_vertical_distance(self) -> float:
        """Returns the vertical distance for dimension line to text midpoint.
        Positive values are above the line, negative values are below the line.

        """
        if self.text_valign == 0:
            return self.text_height * self.text_vertical_position
        else:
            return (
                self.text_height / 2.0 + self.text_gap
            ) * self.vertical_placement

    def text_width(self, text: str) -> float:
        """
        Return width of `text` in drawing units.

        """
        # todo: use matplotlib support
        char_width = self.text_height * self.text_width_factor
        return len(text) * char_width

    def text_override(self, measurement: float) -> str:
        """Create dimension text for `measurement` in drawing units and applies
        text overriding properties.

        """
        text = self.stored_dim_text
        if text == " ":  # suppresses text
            return ""
        elif text == "" or text == "<>":  # measured distance
            return self.format_text(measurement)
        else:  # user override
            return text

    def location_override(
        self, location: UVec, leader=False, relative=False
    ) -> None:
        """Set user defined dimension text location. ezdxf defines a user
        defined location per definition as 'outside'.

        Args:
            location: text midpoint
            leader: use leader or not (movement rules)
            relative: is location absolute (in UCS) or relative to dimension
                line center.

        """
        self.user_location = Vec2(location)
        self.text_movement_rule = 1 if leader else 2
        self.relative_user_location = relative
        self.text_is_outside = True

    def dxfattribs(self) -> Dict[str, Any]:
        return {"color": self.text_color}

    @abc.abstractmethod
    def update(self, raw_measurement_value: float) -> None:
        """Update raw measurement value, scaled measurement value and
        dimension text.

        """

    @abc.abstractmethod
    def format_text(self, value: float) -> str:
        """Rounding and text formatting of `value`, removes leading and
        trailing zeros if necessary.

        """


class LengthMeasurement(Measurement):
    def update(self, raw_measurement_value: float) -> None:
        """Update raw measurement value, scaled measurement value and
        dimension text.
        """
        self.raw_value = raw_measurement_value
        self.value = raw_measurement_value * self.measurement_factor
        self.text = self.text_override(self.value)

    def format_text(self, value: float) -> str:
        """Rounding and text formatting of `value`, removes leading and
        trailing zeros if necessary.

        """
        text = format_text(
            value,
            self.text_round,
            self.decimal_places,
            self.suppress_zeros,
            self.decimal_separator,
        )
        if self.text_post_process_format:
            text = apply_dimpost(text, self.text_post_process_format)
        return text


class Geometry:
    """
    Geometry layout entities are located in the OCS defined by the extrusion
    vector of the DIMENSION entity and the z-axis of the OCS
    point 'text_midpoint' (group code 11).

    """

    def __init__(
        self,
        dimension: Dimension,
        ucs: UCS,
        layout: "GenericLayoutType",
    ):
        assert dimension.doc is not None, "valid DXF document required"
        self.dimension: Dimension = dimension
        self.doc: Drawing = dimension.doc
        self.dxfversion: str = self.doc.dxfversion
        self.supports_dxf_r2000: bool = self.dxfversion >= "AC1015"
        self.supports_dxf_r2007: bool = self.dxfversion >= "AC1021"
        self.ucs: UCS = ucs
        self.extrusion: Vec3 = ucs.uz
        self.requires_extrusion: bool = not self.extrusion.isclose(Z_AXIS)
        self.layout: GenericLayoutType = layout
        self._text_box: TextBox = TextBox()

    @property
    def has_text_box(self) -> bool:
        return self._text_box.width > 0.0 and self._text_box.height > 0.0

    def set_layout(self, layout: GenericLayoutType) -> None:
        self.layout = layout

    def set_text_box(self, text_box: TextBox) -> None:
        self._text_box = text_box

    def has_block(self, name: str) -> bool:
        return name in self.doc.blocks

    def add_arrow_blockref(
        self,
        name: str,
        insert: Vec2,
        size: float,
        rotation: float,
        dxfattribs: Dict[str, Any],
    ) -> None:
        # OCS of the arrow blocks is defined by the DIMENSION entity!
        # Therefore remove OCS elevation, the elevation is defined by the
        # DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
        # either!
        insert = self.ucs.to_ocs(Vec3(insert)).vec2
        rotation = self.ucs.to_ocs_angle_deg(rotation)
        self.layout.add_arrow_blockref(name, insert, size, rotation, dxfattribs)

    def add_blockref(
        self,
        name: str,
        insert: Vec2,
        rotation: float,
        dxfattribs: Dict[str, Any],
    ) -> None:
        # OCS of the arrow blocks is defined by the DIMENSION entity!
        # Therefore remove OCS elevation, the elevation is defined by the
        # DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
        # either!
        insert = self.ucs.to_ocs(Vec3(insert)).vec2
        dxfattribs["rotation"] = self.ucs.to_ocs_angle_deg(rotation)
        self.layout.add_blockref(name, insert, dxfattribs)

    def add_text(
        self, text: str, pos: Vec2, rotation: float, dxfattribs: Dict[str, Any]
    ) -> None:
        dxfattribs["rotation"] = self.ucs.to_ocs_angle_deg(rotation)
        entity = self.layout.add_text(text, dxfattribs=dxfattribs)
        # OCS of the measurement text is defined by the DIMENSION entity!
        # Therefore remove OCS elevation, the elevation is defined by the
        # DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
        # either!
        entity.set_placement(self.ucs.to_ocs(Vec3(pos)).vec2, align=TextEntityAlignment.MIDDLE_CENTER)

    def add_mtext(
        self, text: str, pos: Vec2, rotation: float, dxfattribs: Dict[str, Any]
    ) -> None:
        # OCS of the measurement text is defined by the DIMENSION entity!
        # Therefore remove OCS elevation, the elevation is defined by the
        # DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
        # either!
        dxfattribs["rotation"] = self.ucs.to_ocs_angle_deg(rotation)
        dxfattribs["insert"] = self.ucs.to_ocs(Vec3(pos)).vec2
        self.layout.add_mtext(text, dxfattribs)

    def add_defpoints(self, points: Iterable[Vec2]) -> None:
        attribs = {
            "layer": "Defpoints",
        }
        for point in points:
            # Despite the fact that the POINT entity has WCS coordinates,
            # the coordinates of defpoints in DIMENSION entities have OCS
            # coordinates.
            location = self.ucs.to_ocs(Vec3(point)).replace(z=0)
            self.layout.add_point(location, dxfattribs=attribs)

    def add_line(
        self,
        start: Vec2,
        end: Vec2,
        dxfattribs: Dict[str, Any],
        remove_hidden_lines=False,
    ) -> None:
        """Add a LINE entity to the geometry layout. Removes parts of the line
        hidden by dimension text if `remove_hidden_lines` is True.

        Args:
            start: start point of line
            end: end point of line
            dxfattribs: additional or overridden DXF attributes
            remove_hidden_lines: removes parts of the line hidden by dimension
                text if ``True``

        """

        def add_line_to_block(start, end):
            # LINE is handled like an OCS entity !?
            self.layout.add_line(
                to_ocs(Vec3(start)).vec2,
                to_ocs(Vec3(end)).vec2,
                dxfattribs=dxfattribs,
            )

        def order(a: Vec2, b: Vec2) -> Tuple[Vec2, Vec2]:
            if (start - a).magnitude < (start - b).magnitude:
                return a, b
            else:
                return b, a

        to_ocs = self.ucs.to_ocs
        if remove_hidden_lines and self.has_text_box:
            text_box = self._text_box
            start_inside = int(text_box.is_inside(start))
            end_inside = int(text_box.is_inside(end))
            inside = start_inside + end_inside
            if inside == 2:  # start and end inside text_box
                return  # do not draw line
            elif inside == 1:  # one point inside text_box or on a border line
                intersection_points = text_box.intersect(
                    ConstructionLine(start, end)
                )
                if len(intersection_points) == 1:
                    # one point inside one point outside -> one intersection point
                    p1 = intersection_points[0]
                else:
                    # second point on a text box border line
                    p1, _ = order(*intersection_points)
                p2 = start if end_inside else end
                add_line_to_block(p1, p2)
                return
            else:
                intersection_points = text_box.intersect(
                    ConstructionLine(start, end)
                )
                if len(intersection_points) == 2:
                    # sort intersection points by distance to start point
                    p1, p2 = order(
                        intersection_points[0], intersection_points[1]
                    )
                    # line[start-p1] - gap - line[p2-end]
                    add_line_to_block(start, p1)
                    add_line_to_block(p2, end)
                    return
                # else: fall through
        add_line_to_block(start, end)

    def add_arc(
        self,
        center: Vec2,
        radius: float,
        start_angle: float,
        end_angle: float,
        dxfattribs: dict = None,
        remove_hidden_lines=False,
    ) -> None:
        """Add a ARC entity to the geometry layout. Removes parts of the arc
        hidden by dimension text if `remove_hidden_lines` is True.

        Args:
            center: center of arc
            radius: radius of arc
            start_angle: start angle in radians
            end_angle: end angle in radians
            dxfattribs: additional or overridden DXF attributes
            remove_hidden_lines: removes parts of the arc hidden by dimension
                text if ``True``

        """

        def add_arc(s: float, e: float) -> None:
            """Add ARC entity to geometry block."""
            self.layout.add_arc(
                center=ocs_center,
                radius=radius,
                start_angle=math.degrees(ocs_angle(s)),
                end_angle=math.degrees(ocs_angle(e)),
                dxfattribs=dxfattribs,
            )

        # OCS of the ARC is defined by the DIMENSION entity!
        # Therefore remove OCS elevation, the elevation is defined by the
        # DIMENSION 'text_midpoint' (group code 11) and do not set 'extrusion'
        # either!
        ocs_center = self.ucs.to_ocs(Vec3(center)).vec2
        ocs_angle = self.ucs.to_ocs_angle_rad
        if remove_hidden_lines and self.has_text_box:
            for start, end in visible_arcs(
                center,
                radius,
                start_angle,
                end_angle,
                self._text_box,
            ):
                add_arc(start, end)
        else:
            add_arc(start_angle, end_angle)


class BaseDimensionRenderer:
    """Base rendering class for DIMENSION entities."""

    def __init__(
        self,
        dimension: Dimension,
        ucs: UCS = None,
        override: DimStyleOverride = None,
    ):
        self.dimension: Dimension = dimension
        self.geometry = self.init_geometry(dimension, ucs)

        # DimStyleOverride object, manages dimension style overriding
        self.dim_style: DimStyleOverride
        if override:
            self.dim_style = override
        else:
            self.dim_style = DimStyleOverride(dimension)

        # ---------------------------------------------
        # GENERAL PROPERTIES
        # ---------------------------------------------
        self.default_color: int = self.dimension.dxf.color  # ACI
        self.default_layer: str = self.dimension.dxf.layer

        get = self.dim_style.get
        # Overall scaling of DIMENSION entity:
        self.dim_scale: float = get("dimscale", 1.0)
        if abs(self.dim_scale) < 1e-9:
            self.dim_scale = 1.0

        # Controls drawing of circle or arc center marks and center lines, for
        # DIMDIAMETER and DIMRADIUS, the center mark is drawn only if you place
        # the dimension line outside the circle or arc.
        # 0 = No center marks or lines are drawn
        # <0 = Center lines are drawn
        # >0 = Center marks are drawn
        self.dim_center_marks: int = get("dimcen", 0)

        self.measurement = self.init_measurement(
            self.default_color, self.dim_scale
        )
        self.dimension_line: DimensionLine = self.init_dimension_line(
            self.default_color, self.dim_scale
        )
        self.arrows: Arrows = self.init_arrows(
            self.default_color, self.dim_scale
        )
        # Suppress arrow rendering - only rendering is suppressed (rendering
        # effect).
        # All placing related calculations are done without this settings.
        # Used for multi point linear dimensions to avoid double rendering of
        # non arrow ticks. These are ezdxf specific attributes!
        self.arrows.suppress1 = self.dim_style.pop("suppress_arrow1", False)
        self.arrows.suppress2 = self.dim_style.pop("suppress_arrow2", False)

        self.extension_lines: ExtensionLines = self.init_extension_lines(
            self.default_color, self.dim_scale
        )
        # tolerances have to be initialized after measurement:
        self.tol: Tolerance = self.init_tolerance(
            self.dim_scale, self.measurement
        )

        # Update text height
        self.measurement.text_height = max(
            self.measurement.text_height, self.tol.text_height
        )

    def init_geometry(self, dimension: Dimension, ucs: UCS = None):
        from ezdxf.layouts import VirtualLayout

        return Geometry(dimension, ucs or PassTroughUCS(), VirtualLayout())

    def init_tolerance(
        self, scale: float, measurement: Measurement
    ) -> Tolerance:
        return Tolerance(
            self.dim_style,
            cap_height=measurement.text_height,
            width_factor=measurement.text_width_factor,
            dim_scale=scale,
        )

    def init_extension_lines(self, color: int, scale: float) -> ExtensionLines:
        return ExtensionLines(self.dim_style, color, scale)

    def init_dimension_line(self, color: int, scale: float) -> DimensionLine:
        return DimensionLine(self.dim_style, color, scale)

    def init_arrows(self, color: int, scale: float) -> Arrows:
        return Arrows(self.dim_style, color, scale)

    def init_measurement(self, color: int, scale: float) -> Measurement:
        return LengthMeasurement(self.dim_style, color, scale)

    def init_text_box(self) -> TextBox:
        measurement = self.measurement
        return TextBox(
            center=measurement.text_location,
            width=self.total_text_width(),
            height=measurement.text_height,
            angle=measurement.text_rotation or 0.0,
            # The currently used monospaced abstract font, returns a too large
            # text width.
            # Therefore, the horizontal text gap is ignored at all - yet!
            hgap=0.0,
            # Arbitrary choice to reduce the too large vertical gap!
            vgap=measurement.text_gap * 0.75,
        )

    def get_required_defpoint(self, name: str) -> Vec2:
        return get_required_defpoint(self.dimension, name)

    def render(self, block: GenericLayoutType):
        # Block entities are located in the OCS defined by the extrusion vector
        # of the DIMENSION entity and the z-axis of the OCS point
        # 'text_midpoint' (group code 11).
        self.geometry.set_layout(block)
        # Tolerance requires MTEXT support, switch off rendering of tolerances
        # and limits
        if not self.geometry.supports_dxf_r2000:
            self.tol.disable()

    def total_text_width(self) -> float:
        width = 0.0
        text = self.measurement.text
        if text:
            if self.tol.has_limits:  # only limits are displayed
                width = self.tol.text_width
            else:
                width = self.measurement.text_width(text)
                if self.tol.has_tolerance:
                    width += self.tol.text_width  # type: ignore
        return width

    def default_attributes(self) -> Dict[str, Any]:
        """Returns default DXF attributes as dict."""
        return {
            "layer": self.default_layer,
            "color": self.default_color,
        }

    def location_override(
        self, location: UVec, leader=False, relative=False
    ) -> None:
        """Set user defined dimension text location. ezdxf defines a user
        defined location per definition as 'outside'.

        Args:
            location: text midpoint
            leader: use leader or not (movement rules)
            relative: is location absolute (in UCS) or relative to dimension
                line center.

        """
        self.dim_style.set_location(location, leader, relative)
        self.measurement.location_override(location, leader, relative)

    def add_line(
        self,
        start: Vec2,
        end: Vec2,
        dxfattribs: Dict[str, Any],
        remove_hidden_lines=False,
    ) -> None:
        """Add a LINE entity to the dimension BLOCK. Remove parts of the line
        hidden by dimension text if `remove_hidden_lines` is True.

        Args:
            start: start point of line
            end: end point of line
            dxfattribs: additional or overridden DXF attributes
            remove_hidden_lines: removes parts of the line hidden by dimension
                text if ``True``

        """

        attribs = self.default_attributes()
        if dxfattribs:
            attribs.update(dxfattribs)
        self.geometry.add_line(start, end, dxfattribs, remove_hidden_lines)

    def add_arc(
        self,
        center: Vec2,
        radius: float,
        start_angle: float,
        end_angle: float,
        dxfattribs: Dict[str, Any] = None,
        remove_hidden_lines=False,
    ) -> None:
        """Add a ARC entity to the geometry layout. Remove parts of the arc
        hidden by dimension text if `remove_hidden_lines` is True.

        Args:
            center: center of arc
            radius: radius of arc
            start_angle: start angle in radians
            end_angle: end angle in radians
            dxfattribs: additional or overridden DXF attributes
            remove_hidden_lines: removes parts of the arc hidden by dimension
                text if ``True``

        """
        attribs = self.default_attributes()
        if dxfattribs:
            attribs.update(dxfattribs)
        self.geometry.add_arc(
            center, radius, start_angle, end_angle, attribs, remove_hidden_lines
        )

    def add_blockref(
        self,
        name: str,
        insert: Vec2,
        rotation: float,
        scale: float,
        dxfattribs: Dict[str, Any],
    ) -> None:
        """
        Add block references and standard arrows to the dimension BLOCK.

        Args:
            name: block or arrow name
            insert: insertion point in UCS
            rotation: rotation angle in degrees in UCS (x-axis is 0 degrees)
            scale: scaling factor for x- and y-direction
            dxfattribs: additional or overridden DXF attributes

        """
        attribs = self.default_attributes()
        if dxfattribs:
            attribs.update(dxfattribs)

        if name in ARROWS:
            # generates automatically BLOCK definitions for arrows if needed
            self.geometry.add_arrow_blockref(
                name, insert, scale, rotation, attribs
            )
        else:
            if name is None or not self.geometry.has_block(name):
                raise DXFUndefinedBlockError(f'Undefined block: "{name}"')
            if scale != 1.0:
                attribs["xscale"] = scale
                attribs["yscale"] = scale
            self.geometry.add_blockref(name, insert, rotation, attribs)

    def add_text(
        self, text: str, pos: Vec2, rotation: float, dxfattribs: Dict[str, Any]
    ) -> None:
        """
        Add TEXT (DXF R12) or MTEXT (DXF R2000+) entity to the dimension BLOCK.

        Args:
            text: text as string
            pos: insertion location in UCS
            rotation: rotation angle in degrees in UCS (x-axis is 0 degrees)
            dxfattribs: additional or overridden DXF attributes

        """
        geometry = self.geometry
        measurement = self.measurement
        attribs = self.default_attributes()
        attribs["style"] = measurement.text_style_name
        attribs["color"] = measurement.text_color

        if geometry.supports_dxf_r2000:  # use MTEXT entity
            attribs["char_height"] = measurement.text_height
            attribs["attachment_point"] = measurement.text_attachment_point
            if measurement.text_fill:
                attribs["box_fill_scale"] = measurement.text_box_fill_scale
                attribs["bg_fill_color"] = measurement.text_fill_color
                attribs["bg_fill"] = 3 if measurement.text_fill == 1 else 1

            if dxfattribs:
                attribs.update(dxfattribs)
            geometry.add_mtext(text, pos, rotation, dxfattribs=attribs)
        else:  # use TEXT entity
            attribs["height"] = measurement.text_height
            if dxfattribs:
                attribs.update(dxfattribs)
            geometry.add_text(text, pos, rotation, dxfattribs=attribs)

    def add_leader(self, p1: Vec2, p2: Vec2, p3: Vec2):
        """
        Add simple leader line from p1 to p2 to p3.

        Args:
            p1: target point
            p2: first text point
            p3: second text point

        """
        # use only  color and ignore linetype!
        dxfattribs = {"color": self.dimension_line.color}
        self.add_line(p1, p2, dxfattribs)
        self.add_line(p2, p3, dxfattribs)

    def transform_ucs_to_wcs(self) -> None:
        """Transforms dimension definition points into WCS or if required into
        OCS.

        Can not be called in __init__(), because inherited classes may be need
        unmodified values.

        """
        pass

    def finalize(self) -> None:
        self.transform_ucs_to_wcs()
        if self.geometry.requires_extrusion:
            self.dimension.dxf.extrusion = self.geometry.extrusion


def order_leader_points(p1: Vec2, p2: Vec2, p3: Vec2) -> Tuple[Vec2, Vec2]:
    if (p1 - p2).magnitude > (p1 - p3).magnitude:
        return p3, p2
    else:
        return p2, p3


def get_center_leader_points(
    target_point: Vec2, text_box: TextBox, leg_length: float
) -> Tuple[Vec2, Vec2]:
    """Returns the leader points of the "leg" for a vertical centered leader."""
    c0, c1, c2, c3 = text_box.corners
    #              c3-------c2
    # left leg /---x  text  x---\ right leg
    #         /    c0-------c1   \
    left_center = c0.lerp(c3)
    right_center = c1.lerp(c2)
    connection_point = left_center
    leg_vector = (c0 - c1).normalize(leg_length)
    if target_point.distance(left_center) > target_point.distance(right_center):
        connection_point = right_center
        leg_vector = -leg_vector
    # leader line: target_point -> leg_point -> connection_point
    # The text gap between the text and the connection point is already included
    # in the text_box corners!
    # Do not order leader points!
    return connection_point + leg_vector, connection_point


def get_required_defpoint(dim: Dimension, name: str) -> Vec2:
    dxf = dim.dxf
    if dxf.hasattr(name):  # has to exist, ignore default value!
        return Vec2(dxf.get(name))
    raise const.DXFMissingDefinitionPoint(name)


def visible_arcs(
    center: Vec2,
    radius: float,
    start_angle: float,
    end_angle: float,
    box: ConstructionBox,
) -> List[Tuple[float, float]]:
    """Returns the visible parts of an arc intersecting with a construction box
    as (start angle, end angle) tuples.

    Args:
        center: center of the arc
        radius: radius of the arc
        start_angle: start angle of arc in radians
        end_angle: end angle of arc in radians
        box: construction box which may intersect the arc

    """

    intersection_angles: List[float] = []  # angles are in the range 0 to 2pi
    start_angle %= math.tau
    end_angle %= math.tau
    arc = ConstructionArc(
        center, radius, math.degrees(start_angle), math.degrees(end_angle)
    )
    for line in box.border_lines():
        for intersection_point in arc.intersect_line(line):
            angle = (intersection_point - center).angle % math.tau
            if not intersection_angles:
                intersection_angles.append(angle)
            # new angle should be different than the last added angle:
            elif not math.isclose(intersection_angles[-1], angle):
                intersection_angles.append(angle)
    # Arc has to intersect the box in exact two locations!
    if len(intersection_angles) == 2:
        if start_angle > end_angle:  # arc passes 0 degrees
            intersection_angles = [
                (a if a >= start_angle else a + math.tau)
                for a in intersection_angles
            ]
        intersection_angles.sort()
        return [
            (start_angle, intersection_angles[0]),
            (intersection_angles[1], end_angle),
        ]
    else:
        # Ignore cases where the start- or the end point is inside the box.
        # Ignore cases where the box touches the arc in one point.
        return [(start_angle, end_angle)]


def get_text_style(doc: "Drawing", name: str) -> Textstyle:
    assert doc is not None, "valid DXF document required"
    get_style = doc.tables.styles.get
    try:
        style = get_style(name)
    except const.DXFTableEntryError:
        style = get_style("Standard")
    return cast("Textstyle", style)


def get_char_height(
    dim_style: DimStyleOverride, text_style: Textstyle
) -> float:
    """Unscaled character height defined by text style or DIMTXT."""
    height: float = text_style.dxf.get("height", 0.0)
    if height == 0.0:  # variable text height (not fixed)
        height = dim_style.get("dimtxt", 1.0)
    return height


def compile_mtext(measurement: Measurement, tol: Tolerance) -> str:
    text = measurement.text
    if tol.enabled:
        text = tol.compile_mtext(text)
    return text
