# Copyright (c) 2019-2023 Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Optional
import math
from ezdxf.lldxf import validator
from ezdxf.lldxf import const
from ezdxf.lldxf.attributes import (
    DXFAttr,
    DXFAttributes,
    DefSubclass,
    XType,
    RETURN_DEFAULT,
    group_code_mapping,
)
from ezdxf.lldxf.types import DXFTag, DXFVertex
from ezdxf.lldxf.tags import Tags
from ezdxf.lldxf.const import (
    DXF12,
    SUBCLASS_MARKER,
    DXFStructureError,
    DXFValueError,
    DXFTableEntryError,
)
from ezdxf.math import (
    Vec3,
    Vec2,
    NULLVEC,
    X_AXIS,
    Y_AXIS,
    Z_AXIS,
    Matrix44,
    BoundingBox2d,
)
from ezdxf.tools import set_flag_state
from .dxfentity import base_class, SubclassProcessor
from .dxfgfx import DXFGraphic, acdb_entity
from .factory import register_entity

if TYPE_CHECKING:
    from ezdxf.document import Drawing
    from ezdxf.entities import DXFNamespace, DXFEntity
    from ezdxf.lldxf.tagwriter import AbstractTagWriter
    from ezdxf import xref

__all__ = ["Viewport"]

acdb_viewport = DefSubclass(
    "AcDbViewport",
    {
        # DXF reference: Center point (in WCS)
        # Correction to the DXF reference:
        # This point represents the center of the viewport in paper space units
        # (DCS), but is stored as 3D point inclusive z-axis!
        "center": DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
        # Width in paper space units:
        "width": DXFAttr(40, default=1),
        # Height in paper space units:
        "height": DXFAttr(41, default=1),
        # Viewport status field: (according to the DXF Reference)
        # -1 = On, but is fully off-screen, or is one of the viewports that is not
        #      active because the $MAXACTVP count is currently being exceeded.
        #  0 = Off
        # <positive value> = On and active. The value indicates the order of
        # stacking for the viewports, where 1 is the "active" viewport, 2 is the
        # next, and so on. The "active" viewport determines how the paperspace layout
        # is presented as a whole (location & zoom state)
        "status": DXFAttr(68, default=0),
        # Viewport id: (according to the DXF Reference)
        # Special VIEWPORT id == 1, this viewport defines the area of the layout
        # which is currently shown in the layout tab by the CAD application.
        # I guess this is meant by "active viewport" and therefore it is most likely
        # that this id is always 1.
        # This "active viewport" is mandatory for a valid DXF file.
        # BricsCAD set this id to -1 if the viewport is off and 'status' (group code 68)
        # is not present.
        "id": DXFAttr(69, default=2),
        # DXF reference: View center point (in WCS):
        # Correction to the DXF reference:
        # This point represents the center point in model space (WCS) stored as
        # 2D point!
        "view_center_point": DXFAttr(12, xtype=XType.point2d, default=NULLVEC),
        "snap_base_point": DXFAttr(13, xtype=XType.point2d, default=NULLVEC),
        "snap_spacing": DXFAttr(14, xtype=XType.point2d, default=Vec2(10, 10)),
        "grid_spacing": DXFAttr(15, xtype=XType.point2d, default=Vec2(10, 10)),
        # View direction vector (WCS):
        "view_direction_vector": DXFAttr(16, xtype=XType.point3d, default=Z_AXIS),
        # View target point (in WCS):
        "view_target_point": DXFAttr(17, xtype=XType.point3d, default=NULLVEC),
        "perspective_lens_length": DXFAttr(42, default=50),
        "front_clip_plane_z_value": DXFAttr(43, default=0),
        "back_clip_plane_z_value": DXFAttr(44, default=0),
        # View height (in model space units):
        "view_height": DXFAttr(45, default=1),
        "snap_angle": DXFAttr(50, default=0),
        "view_twist_angle": DXFAttr(51, default=0),
        "circle_zoom": DXFAttr(72, default=100),
        # 331: Frozen layer object ID/handle (multiple entries may exist) (optional)
        # Viewport status bit-coded flags:
        # 1 (0x1) = Enables perspective mode
        # 2 (0x2) = Enables front clipping
        # 4 (0x4) = Enables back clipping
        # 8 (0x8) = Enables UCS follow
        # 16 (0x10) = Enables front clip not at eye
        # 32 (0x20) = Enables UCS icon visibility
        # 64 (0x40) = Enables UCS icon at origin
        # 128 (0x80) = Enables fast zoom
        # 256 (0x100) = Enables snap mode
        # 512 (0x200) = Enables grid mode
        # 1024 (0x400) = Enables isometric snap style
        # 2048 (0x800) = Enables hide plot mode
        # 4096 (0x1000) = kIsoPairTop. If set and kIsoPairRight is not set, then
        #   isopair top is enabled. If both kIsoPairTop and kIsoPairRight are set,
        #   then isopair left is enabled
        # 8192 (0x2000) = kIsoPairRight. If set and kIsoPairTop is not set, then
        #   isopair right is enabled
        # 16384 (0x4000) = Enables viewport zoom locking
        # 32768 (0x8000) = Currently always enabled
        # 65536 (0x10000) = Enables non-rectangular clipping
        # 131072 (0x20000) = Turns the viewport off
        # 262144 (0x40000) = Enables the display of the grid beyond the drawing
        #   limits
        # 524288 (0x80000) = Enable adaptive grid display
        # 1048576 (0x100000) = Enables subdivision of the grid below the set grid
        #   spacing when the grid display is adaptive
        # 2097152 (0x200000) = Enables grid follows workplane switching
        "flags": DXFAttr(90, default=0),
        # Clipping viewports: the following handle point to a graphical entity
        # located in the paperspace. Known supported entities:
        # LWPOLYLINE (2D POLYLINE), CIRCLE, ELLIPSE, closed SPLINE
        # Extract bounding- or clipping path: ezdxf.render.make_path()
        "clipping_boundary_handle": DXFAttr(340, default="0", optional=True),
        # Plot style sheet name assigned to this viewport
        "plot_style_name": DXFAttr(1, default=""),
        # Render mode:
        # 0 = 2D Optimized (classic 2D)
        # 1 = Wireframe
        # 2 = Hidden line
        # 3 = Flat shaded
        # 4 = Gouraud shaded
        # 5 = Flat shaded with wireframe
        # 6 = Gouraud shaded with wireframe
        "render_mode": DXFAttr(
            281,
            default=0,
            validator=validator.is_in_integer_range(0, 7),
            fixer=RETURN_DEFAULT,
        ),
        "ucs_per_viewport": DXFAttr(
            71,
            default=0,
            validator=validator.is_integer_bool,
            fixer=RETURN_DEFAULT,
        ),
        "ucs_icon": DXFAttr(74, default=0),
        "ucs_origin": DXFAttr(110, xtype=XType.point3d, default=NULLVEC),
        "ucs_x_axis": DXFAttr(
            111,
            xtype=XType.point3d,
            default=X_AXIS,
            validator=validator.is_not_null_vector,
            fixer=RETURN_DEFAULT,
        ),
        "ucs_y_axis": DXFAttr(
            112,
            xtype=XType.point3d,
            default=Y_AXIS,
            validator=validator.is_not_null_vector,
            fixer=RETURN_DEFAULT,
        ),
        # Handle of AcDbUCSTableRecord if UCS is a named UCS.
        # If not present, then UCS is unnamed:
        "ucs_handle": DXFAttr(345),
        # Handle of AcDbUCSTableRecord of base UCS if UCS is orthographic (79 code
        # is non-zero). If not present and 79 code is non-zero, then base UCS is
        # taken to be WORLD:
        "base_ucs_handle": DXFAttr(346, optional=True),
        # UCS ortho type:
        # 0 = not orthographic
        # 1 = Top
        # 2 = Bottom
        # 3 = Front
        # 4 = Back
        # 5 = Left
        # 6 = Right
        "ucs_ortho_type": DXFAttr(
            79,
            default=0,
            validator=validator.is_in_integer_range(0, 7),
            fixer=RETURN_DEFAULT,
        ),
        "elevation": DXFAttr(146, default=0),
        # Shade plot mode:
        # 0 = As Displayed
        # 1 = Wireframe
        # 2 = Hidden
        # 3 = Rendered
        "shade_plot_mode": DXFAttr(
            170,
            dxfversion="AC1018",
            validator=validator.is_in_integer_range(0, 4),
            fixer=RETURN_DEFAULT,
        ),
        # Frequency of major grid lines compared to minor grid lines
        "grid_frequency": DXFAttr(61, dxfversion="AC1021"),
        "background_handle": DXFAttr(332, dxfversion="AC1021", optional=True),
        "shade_plot_handle": DXFAttr(333, dxfversion="AC1021", optional=True),
        "visual_style_handle": DXFAttr(348, dxfversion="AC1021", optional=True),
        "default_lighting_flag": DXFAttr(
            292, dxfversion="AC1021", default=1, optional=True
        ),
        # Default lighting type:
        # 0 = One distant light
        # 1 = Two distant lights
        "default_lighting_type": DXFAttr(
            282,
            default=0,
            dxfversion="AC1021",
            validator=validator.is_integer_bool,
            fixer=RETURN_DEFAULT,
        ),
        "view_brightness": DXFAttr(141, dxfversion="AC1021"),
        "view_contrast": DXFAttr(142, dxfversion="AC1021"),
        # as AutoCAD Color Index
        "ambient_light_color_1": DXFAttr(
            63,
            dxfversion="AC1021",
            validator=validator.is_valid_aci_color,
        ),
        # as True Color:
        "ambient_light_color_2": DXFAttr(421, dxfversion="AC1021"),
        # as True Color:
        "ambient_light_color_3": DXFAttr(431, dxfversion="AC1021"),
        "sun_handle": DXFAttr(361, dxfversion="AC1021", optional=True),
        # The following attributes are mentioned in the DXF reference but may not really exist:
        # "Soft pointer reference to viewport object (for layer VP property override)"
        "ref_vp_object_1": DXFAttr(335, dxfversion="AC1021"),  # soft-pointer
        "ref_vp_object_2": DXFAttr(343, dxfversion="AC1021"),  # hard-pointer
        "ref_vp_object_3": DXFAttr(344, dxfversion="AC1021"),  # hard-pointer
        "ref_vp_object_4": DXFAttr(91, dxfversion="AC1021"),  # this is not a pointer!
    },
)
acdb_viewport_group_codes = group_code_mapping(acdb_viewport)
# Note:
# The ZOOM XP factor is calculated with the following formula:
# group_41 / group_45 (or pspace_height / mspace_height).

FROZEN_LAYER_GROUP_CODE = 331


@register_entity
class Viewport(DXFGraphic):
    """DXF VIEWPORT entity"""

    DXFTYPE = "VIEWPORT"
    DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_viewport)

    # Notes to viewport_id:
    # The id of the first viewport has to be 1, which is the definition of
    # paper space. For the following viewports it seems only important, that
    # the id is greater than 1.

    def __init__(self) -> None:
        super().__init__()
        self._frozen_layers: list[str] = []

    def copy_data(self, entity: DXFEntity) -> None:
        assert isinstance(entity, Viewport)
        entity._frozen_layers = list(self._frozen_layers)

    @property
    def frozen_layers(self) -> list[str]:
        """Set/get frozen layers as list of layer names."""
        return self._frozen_layers

    @frozen_layers.setter
    def frozen_layers(self, names: Iterable[str]):
        self._frozen_layers = list(names)

    def _layer_index(self, layer_name: str) -> int:
        name_key = validator.make_table_key(layer_name)
        for index, name in enumerate(self._frozen_layers):
            if name_key == validator.make_table_key(name):
                return index
        return -1

    def freeze(self, layer_name: str) -> None:
        """Freeze `layer_name` in this viewport."""
        index = self._layer_index(layer_name)
        if index == -1:
            self._frozen_layers.append(layer_name)

    def is_frozen(self, layer_name: str) -> bool:
        """Returns ``True`` if `layer_name` id frozen in this viewport."""
        return self._layer_index(layer_name) != -1

    def thaw(self, layer_name: str) -> None:
        """Thaw `layer_name` in this viewport."""
        index = self._layer_index(layer_name)
        if index != -1:
            del self._frozen_layers[index]

    @property
    def is_visible(self) -> bool:
        # VIEWPORT id == 1 or status == 1, this viewport defines the "active viewport"
        # which is the area currently shown in the layout tab by the CAD
        # application.
        # BricsCAD set id to -1 if the viewport is off and 'status' (group
        # code 68) is not present.
        # status: -1= off-screen, 0= off, 1= "active viewport"
        if self.dxf.hasattr("status"):
            return self.dxf.status > 0
        return self.dxf.id > 1

    def load_dxf_attribs(
        self, processor: Optional[SubclassProcessor] = None
    ) -> DXFNamespace:
        dxf = super().load_dxf_attribs(processor)
        if processor:
            tags = processor.fast_load_dxfattribs(
                dxf, acdb_viewport_group_codes, subclass=2, log=False
            )
            if processor.r12:
                self.load_xdata_into_dxf_namespace()
            else:
                if len(tags):
                    tags = self.load_frozen_layer_handles(tags)
                if len(tags):
                    processor.log_unprocessed_tags(tags, subclass=acdb_viewport.name)
        return dxf

    def post_load_hook(self, doc: Drawing):
        super().post_load_hook(doc)
        bag: list[str] = []
        db = doc.entitydb
        for handle in self._frozen_layers:
            try:
                bag.append(db[handle].dxf.name)
            except KeyError:  # ignore non-existing layers
                pass
        self._frozen_layers = bag

    def load_frozen_layer_handles(self, tags: Tags) -> Tags:
        unprocessed_tags = Tags()
        for tag in tags:
            if tag.code == FROZEN_LAYER_GROUP_CODE:
                self._frozen_layers.append(tag.value)
            else:
                unprocessed_tags.append(tag)
        return unprocessed_tags

    def load_xdata_into_dxf_namespace(self) -> None:
        try:
            tags = [v for c, v in self.xdata.get_xlist("ACAD", "MVIEW")]  # type: ignore
        except DXFValueError:
            return
        tags = tags[3:-2]
        dxf = self.dxf
        flags = 0
        flags = set_flag_state(flags, const.VSF_FAST_ZOOM, bool(tags[11]))
        flags = set_flag_state(flags, const.VSF_SNAP_MODE, bool(tags[13]))
        flags = set_flag_state(flags, const.VSF_GRID_MODE, bool(tags[14]))
        flags = set_flag_state(flags, const.VSF_ISOMETRIC_SNAP_STYLE, bool(tags[15]))
        flags = set_flag_state(flags, const.VSF_HIDE_PLOT_MODE, bool(tags[24]))
        try:
            dxf.view_target_point = tags[0]
            dxf.view_direction_vector = tags[1]
            dxf.view_twist_angle = tags[2]
            dxf.view_height = tags[3]
            dxf.view_center_point = tags[4], tags[5]
            dxf.perspective_lens_length = tags[6]
            dxf.front_clip_plane_z_value = tags[7]
            dxf.back_clip_plane_z_value = tags[8]
            dxf.render_mode = tags[9]  # view_mode == render_mode ?
            dxf.circle_zoom = tags[10]
            # fast zoom flag : tag[11]
            dxf.ucs_icon = tags[12]
            # snap mode flag  = tags[13]
            # grid mode flag = tags[14]
            # isometric snap style = tags[15]
            # dxf.snap_isopair = tags[16] ???
            dxf.snap_angle = tags[17]
            dxf.snap_base_point = tags[18], tags[19]
            dxf.snap_spacing = tags[20], tags[21]
            dxf.grid_spacing = tags[22], tags[23]
            # hide plot flag  = tags[24]
        except IndexError:  # internal exception
            raise DXFStructureError("Invalid viewport entity - missing data")
        dxf.flags = flags
        self._frozen_layers = tags[26:]
        self.xdata.discard("ACAD")  # type: ignore

    def export_entity(self, tagwriter: AbstractTagWriter) -> None:
        """Export entity specific data as DXF tags."""
        super().export_entity(tagwriter)
        if tagwriter.dxfversion == DXF12:
            self.export_acdb_viewport_r12(tagwriter)
        else:
            tagwriter.write_tag2(SUBCLASS_MARKER, acdb_viewport.name)
            self.dxf.export_dxf_attribs(
                tagwriter,
                [
                    "center",
                    "width",
                    "height",
                    "status",
                    "id",
                    "view_center_point",
                    "snap_base_point",
                    "snap_spacing",
                    "grid_spacing",
                    "view_direction_vector",
                    "view_target_point",
                    "perspective_lens_length",
                    "front_clip_plane_z_value",
                    "back_clip_plane_z_value",
                    "view_height",
                    "snap_angle",
                    "view_twist_angle",
                    "circle_zoom",
                ],
            )
            if len(self.frozen_layers):
                assert self.doc is not None, "valid DXF document required"
                layers = self.doc.layers
                for layer_name in self.frozen_layers:
                    try:
                        layer = layers.get(layer_name)
                    except DXFTableEntryError:
                        pass
                    else:
                        tagwriter.write_tag2(FROZEN_LAYER_GROUP_CODE, layer.dxf.handle)

            self.dxf.export_dxf_attribs(
                tagwriter,
                [
                    "flags",
                    "clipping_boundary_handle",
                    "plot_style_name",
                    "render_mode",
                    "ucs_per_viewport",
                    "ucs_icon",
                    "ucs_origin",
                    "ucs_x_axis",
                    "ucs_y_axis",
                    "ucs_handle",
                    "base_ucs_handle",
                    "ucs_ortho_type",
                    "elevation",
                    "shade_plot_mode",
                    "grid_frequency",
                    "background_handle",
                    "shade_plot_handle",
                    "visual_style_handle",
                    "default_lighting_flag",
                    "default_lighting_type",
                    "view_brightness",
                    "view_contrast",
                    "ambient_light_color_1",
                    "ambient_light_color_2",
                    "ambient_light_color_3",
                    "sun_handle",
                    "ref_vp_object_1",
                    "ref_vp_object_2",
                    "ref_vp_object_3",
                    "ref_vp_object_4",
                ],
            )

    def export_acdb_viewport_r12(self, tagwriter: AbstractTagWriter):
        self.dxf.export_dxf_attribs(
            tagwriter,
            [
                "center",
                "width",
                "height",
                "status",
                "id",
            ],
        )
        tagwriter.write_tags(self.dxftags())

    def dxftags(self) -> Tags:
        def flag(flag):
            return 1 if self.dxf.flags & flag else 0

        dxf = self.dxf
        tags = [
            DXFTag(1001, "ACAD"),
            DXFTag(1000, "MVIEW"),
            DXFTag(1002, "{"),
            DXFTag(1070, 16),  # extended data version, always 16 for R11/12
            DXFVertex(1010, dxf.view_target_point),
            DXFVertex(1010, dxf.view_direction_vector),
            DXFTag(1040, dxf.view_twist_angle),
            DXFTag(1040, dxf.view_height),
            DXFTag(1040, dxf.view_center_point[0]),
            DXFTag(
                1040,
                dxf.view_center_point[1],
            ),
            DXFTag(1040, dxf.perspective_lens_length),
            DXFTag(1040, dxf.front_clip_plane_z_value),
            DXFTag(1040, dxf.back_clip_plane_z_value),
            DXFTag(1070, dxf.render_mode),
            DXFTag(1070, dxf.circle_zoom),
            DXFTag(1070, flag(const.VSF_FAST_ZOOM)),
            DXFTag(1070, dxf.ucs_icon),
            DXFTag(1070, flag(const.VSF_SNAP_MODE)),
            DXFTag(1070, flag(const.VSF_GRID_MODE)),
            DXFTag(1070, flag(const.VSF_ISOMETRIC_SNAP_STYLE)),
            DXFTag(1070, 0),  # snap isopair ???
            DXFTag(1040, dxf.snap_angle),
            DXFTag(1040, dxf.snap_base_point[0]),
            DXFTag(1040, dxf.snap_base_point[1]),
            DXFTag(1040, dxf.snap_spacing[0]),
            DXFTag(1040, dxf.snap_spacing[1]),
            DXFTag(1040, dxf.grid_spacing[0]),
            DXFTag(1040, dxf.grid_spacing[1]),
            DXFTag(1070, flag(const.VSF_HIDE_PLOT_MODE)),
            DXFTag(1002, "{"),  # start frozen layer list
        ]
        tags.extend(DXFTag(1003, layer_name) for layer_name in self.frozen_layers)
        tags.extend(
            [
                DXFTag(1002, "}"),  # end of frozen layer list
                DXFTag(1002, "}"),  # MVIEW
            ]
        )
        return Tags(tags)

    def register_resources(self, registry: xref.Registry) -> None:
        assert self.doc is not None
        super().register_resources(registry)
        # The clipping path entity should not be added here!
        registry.add_handle(self.dxf.get("ucs_handle"))
        registry.add_handle(self.dxf.get("base_ucs_handle"))
        registry.add_handle(self.dxf.get("visual_style_handle"))
        registry.add_handle(self.dxf.get("background_handle"))
        registry.add_handle(self.dxf.get("shade_plot_handle"))
        registry.add_handle(self.dxf.get("sun_handle"))

    def map_resources(self, clone: DXFEntity, mapping: xref.ResourceMapper) -> None:
        assert isinstance(clone, Viewport)
        super().map_resources(clone, mapping)

        mapping.map_existing_handle(
            self, clone, "clipping_boundary_handle", optional=True
        )
        mapping.map_existing_handle(self, clone, "ucs_handle", optional=True)
        mapping.map_existing_handle(self, clone, "base_ucs_handle", optional=True)
        mapping.map_existing_handle(self, clone, "visual_style_handle", optional=True)
        mapping.map_existing_handle(self, clone, "sun_handle", optional=True)
        # VIEWPORT entity is hard owner of the SUN object
        clone.take_sun_ownership()
        clone.frozen_layers = [mapping.get_layer(name) for name in self.frozen_layers]

        # I have no information to what entities the background- and the shade_plot
        # handles are pointing to and I don't have any examples for that!
        mapping.map_existing_handle(self, clone, "background_handle", optional=True)
        mapping.map_existing_handle(self, clone, "shade_plot_handle", optional=True)

        # No information if these attributes really exist or any examples where these
        # attributes are used. BricsCAD does not create these attributes when using
        # viewport layer overrides:
        for num in range(1, 5):
            clone.dxf.discard(f"ref_vp_object_{num}")

    def take_sun_ownership(self) -> None:
        assert self.doc is not None
        sun = self.doc.entitydb.get(self.dxf.get("sun_handle"))
        if sun:
            sun.dxf.owner = self.dxf.handle

    def rename_frozen_layer(self, old_name: str, new_name: str) -> None:
        assert self.doc is not None, "valid DXF document required"
        key = self.doc.layers.key
        old_key = key(old_name)
        self.frozen_layers = [
            (name if key(name) != old_key else new_name) for name in self.frozen_layers
        ]

    def clipping_rect_corners(self) -> list[Vec2]:
        """Returns the default rectangular clipping path as list of
        vertices. Use function :func:`ezdxf.path.make_path` to get also
        non-rectangular shaped clipping paths if defined.
        """
        center = self.dxf.center
        cx = center.x
        cy = center.y
        width2 = self.dxf.width / 2
        height2 = self.dxf.height / 2
        return [
            Vec2(cx - width2, cy - height2),
            Vec2(cx + width2, cy - height2),
            Vec2(cx + width2, cy + height2),
            Vec2(cx - width2, cy + height2),
        ]

    def clipping_rect(self) -> tuple[Vec2, Vec2]:
        """Returns the lower left and the upper right corner of the clipping
        rectangle in paperspace coordinates.
        """
        corners = self.clipping_rect_corners()
        return corners[0], corners[2]

    @property
    def has_extended_clipping_path(self) -> bool:
        """Returns ``True`` if a non-rectangular clipping path is defined."""
        _flag = self.dxf.flags & const.VSF_NON_RECTANGULAR_CLIPPING
        if _flag:
            handle = self.dxf.clipping_boundary_handle
            return handle != "0"
        return False

    def get_scale(self) -> float:
        """Returns the scaling factor from modelspace to viewport."""
        msp_height = self.dxf.view_height
        if abs(msp_height) < 1e-12:
            return 0.0
        vp_height = self.dxf.height
        return vp_height / msp_height

    @property
    def is_top_view(self) -> bool:
        """Returns ``True`` if the viewport is a top view."""
        view_direction: Vec3 = self.dxf.view_direction_vector
        return view_direction.is_null or view_direction.isclose(Z_AXIS)

    def get_view_center_point(self) -> Vec3:
        # TODO: Is there a flag or attribute that determines which of these points is
        #  the center point?
        center_point = Vec3(self.dxf.view_center_point)
        if center_point.is_null:
            center_point = Vec3(self.dxf.view_target_point)
        return center_point

    def get_transformation_matrix(self) -> Matrix44:
        """Returns the transformation matrix from modelspace to paperspace coordinates.
        """
        # supports only top-view viewports!
        scale = self.get_scale()
        rotation_angle: float = self.dxf.view_twist_angle
        msp_center_point: Vec3 = self.get_view_center_point()
        offset: Vec3 = self.dxf.center - (msp_center_point * scale)
        m = Matrix44.scale(scale)
        if rotation_angle:
            m @= Matrix44.z_rotate(math.radians(rotation_angle))
        return m @ Matrix44.translate(offset.x, offset.y, 0)

    def get_aspect_ratio(self) -> float:
        """Returns the aspect ratio of the viewport, return 0.0 if width or
        height is zero.
        """
        try:
            return self.dxf.width / self.dxf.height
        except ZeroDivisionError:
            return 0.0

    def get_modelspace_limits(self) -> tuple[float, float, float, float]:
        """Returns the limits of the modelspace to view in drawing units
        as tuple (min_x, min_y, max_x, max_y).
        """
        msp_center_point: Vec3 = self.get_view_center_point()
        msp_height: float = self.dxf.view_height
        rotation_angle: float = self.dxf.view_twist_angle
        ratio = self.get_aspect_ratio()
        if ratio == 0.0:
            raise ValueError("invalid viewport parameters width or height")

        w2 = msp_height * ratio * 0.5
        h2 = msp_height * 0.5
        if rotation_angle:
            frame = Vec2.list(((-w2, -h2), (w2, -h2), (w2, h2), (-w2, h2)))
            angle = math.radians(rotation_angle)
            bbox = BoundingBox2d(v.rotate(angle) + msp_center_point for v in frame)
            return bbox.extmin.x, bbox.extmin.y, bbox.extmax.x, bbox.extmax.y  # type: ignore
        else:
            mx, my, _ = msp_center_point
            return mx - w2, my - h2, mx + w2, my + h2
