# Copyright (c) 2020-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
    List,
    Iterable,
    Iterator,
    Union,
    Optional,
    Callable,
    Type,
    TypeVar,
)
from typing_extensions import TypeAlias
from functools import singledispatch, partial
from ezdxf.math import (
    ABS_TOL,
    Vec2,
    Vec3,
    NULLVEC,
    Z_AXIS,
    OCS,
    Bezier3P,
    Bezier4P,
    ConstructionEllipse,
    BSpline,
    have_bezier_curves_g1_continuity,
    fit_points_to_cad_cv,
    UVec,
    Matrix44,
)
from ezdxf.lldxf import const
from ezdxf.entities import (
    LWPolyline,
    Polyline,
    Hatch,
    Line,
    Spline,
    Ellipse,
    Arc,
    Circle,
    Solid,
    Trace,
    Face3d,
    Viewport,
    Image,
    Helix,
    Wipeout,
    MPolygon,
    BoundaryPaths,
    AbstractBoundaryPath,
    PolylinePath,
    EdgePath,
    LineEdge,
    ArcEdge,
    EllipseEdge,
    SplineEdge,
)
from ezdxf.entities.polygon import DXFPolygon
from .path import Path
from .commands import Command
from . import tools
from .nesting import group_paths

__all__ = [
    "make_path",
    "to_lines",
    "to_polylines3d",
    "to_lwpolylines",
    "to_polylines2d",
    "to_hatches",
    "to_mpolygons",
    "to_bsplines_and_vertices",
    "to_splines_and_polylines",
    "from_hatch",
    "from_hatch_ocs",
    "from_hatch_boundary_path",
    "from_hatch_edge_path",
    "from_hatch_polyline_path",
    "from_vertices",
]

MAX_DISTANCE = 0.01
MIN_SEGMENTS = 4
G1_TOL = 1e-4
TPolygon = TypeVar("TPolygon", Hatch, MPolygon)
BoundaryFactory = Callable[[BoundaryPaths, Path, int], None]


@singledispatch
def make_path(entity, segments: int = 1, level: int = 4) -> Path:
    """Factory function to create a single :class:`Path` object from a DXF
    entity.

    Args:
        entity: DXF entity
        segments: minimal count of cubic Bézier-curves for elliptical arcs
        level: subdivide level for SPLINE approximation

    Raises:
        TypeError: for unsupported DXF types

    """
    # Complete documentation is path.rst, because Sphinx auto-function
    # renders for each overloaded function a signature, which is ugly
    # and wrong signatures for multiple overloaded function
    # e.g. 3 equal signatures for type Solid.
    raise TypeError(f"unsupported DXF type: {entity.dxftype()}")


@make_path.register(LWPolyline)
def _from_lwpolyline(lwpolyline: LWPolyline, **kwargs) -> Path:
    path = Path()
    tools.add_2d_polyline(
        path,
        lwpolyline.get_points("xyb"),
        close=lwpolyline.closed,
        ocs=lwpolyline.ocs(),
        elevation=lwpolyline.dxf.elevation,
        segments=kwargs.get("segments", 1),
    )
    return path


@make_path.register(Polyline)
def _from_polyline(polyline: Polyline, **kwargs) -> Path:
    if polyline.is_polygon_mesh or polyline.is_poly_face_mesh:
        raise TypeError("Unsupported DXF type PolyMesh or PolyFaceMesh")

    path = Path()
    if len(polyline.vertices) == 0:
        return path

    if polyline.is_3d_polyline:
        return from_vertices(polyline.points(), polyline.is_closed)

    points = [vertex.format("xyb") for vertex in polyline.vertices]
    ocs = polyline.ocs()
    if polyline.dxf.hasattr("elevation"):
        elevation = Vec3(polyline.dxf.elevation).z
    else:
        # the elevation attribute is mandatory, but if it's missing
        # take the elevation value of the first vertex.
        elevation = Vec3(polyline.vertices[0].dxf.location).z
    tools.add_2d_polyline(
        path,
        points,
        close=polyline.is_closed,
        ocs=ocs,
        elevation=elevation,
        segments=kwargs.get("segments", 1),
    )
    return path


@make_path.register(Helix)
@make_path.register(Spline)
def _from_spline(spline: Spline, **kwargs) -> Path:
    level = kwargs.get("level", 4)
    path = Path()
    tools.add_spline(path, spline.construction_tool(), level=level, reset=True)
    return path


@make_path.register(Ellipse)
def _from_ellipse(ellipse: Ellipse, **kwargs) -> Path:
    segments = kwargs.get("segments", 1)
    path = Path()
    tools.add_ellipse(path, ellipse.construction_tool(), segments=segments, reset=True)
    return path


@make_path.register(Line)
def _from_line(line: Line, **kwargs) -> Path:
    path = Path(line.dxf.start)
    path.line_to(line.dxf.end)
    return path


@make_path.register(Arc)
def _from_arc(arc: Arc, **kwargs) -> Path:
    segments = kwargs.get("segments", 1)
    path = Path()
    radius = abs(arc.dxf.radius)
    if radius > 1e-12:
        ellipse = ConstructionEllipse.from_arc(
            center=arc.dxf.center,
            radius=radius,
            extrusion=arc.dxf.extrusion,
            start_angle=arc.dxf.start_angle,
            end_angle=arc.dxf.end_angle,
        )
        tools.add_ellipse(path, ellipse, segments=segments, reset=True)
    return path


@make_path.register(Circle)
def _from_circle(circle: Circle, **kwargs) -> Path:
    segments = kwargs.get("segments", 1)
    path = Path()
    radius = abs(circle.dxf.radius)
    if radius > 1e-12:
        ellipse = ConstructionEllipse.from_arc(
            center=circle.dxf.center,
            radius=radius,
            extrusion=circle.dxf.extrusion,
        )
        tools.add_ellipse(path, ellipse, segments=segments, reset=True)
    return path


@make_path.register(Face3d)
@make_path.register(Trace)
@make_path.register(Solid)
def _from_quadrilateral(solid: "Solid", **kwargs) -> Path:
    vertices = solid.wcs_vertices()
    return from_vertices(vertices, close=True)


@make_path.register(Viewport)
def _from_viewport(vp: "Viewport", **kwargs) -> Path:
    if vp.has_extended_clipping_path:
        handle = vp.dxf.clipping_boundary_handle
        if handle != "0" and vp.doc:  # exist
            db = vp.doc.entitydb
            if db:  # exist
                # Many DXF entities can define a clipping path:
                clipping_entity = vp.doc.entitydb.get(handle)
                if clipping_entity:  # exist
                    return make_path(clipping_entity, **kwargs)
    # Return bounding box:
    return from_vertices(vp.clipping_rect_corners(), close=True)


@make_path.register(Wipeout)
@make_path.register(Image)
def _from_image(image: "Image", **kwargs) -> Path:
    return from_vertices(image.boundary_path_wcs(), close=True)


@make_path.register(MPolygon)
@make_path.register(Hatch)
def _from_hatch(hatch: Hatch, **kwargs) -> Path:
    ocs = hatch.ocs()
    elevation = hatch.dxf.elevation.z
    offset = NULLVEC
    if isinstance(hatch, MPolygon):
        offset = hatch.dxf.get("offset_vector", NULLVEC)
    paths = [
        from_hatch_boundary_path(boundary, ocs, elevation, offset=offset)
        for boundary in hatch.paths
    ]
    # looses the boundary path state:
    return tools.to_multi_path(paths)


def from_hatch(hatch: DXFPolygon, offset: Vec3 = NULLVEC) -> Iterator[Path]:
    """Yield all HATCH/MPOLYGON boundary paths as separated :class:`Path` objects in WCS
    coordinates.
    """
    ocs = hatch.ocs()
    elevation = hatch.dxf.elevation.z
    for boundary in hatch.paths:
        p = from_hatch_boundary_path(boundary, ocs, elevation=elevation, offset=offset)
        if p.has_sub_paths:
            yield from p.sub_paths()
        else:
            yield p


def from_hatch_ocs(hatch: DXFPolygon, offset: Vec3 = NULLVEC) -> Iterator[Path]:
    """Yield all HATCH/MPOLYGON boundary paths as separated :class:`Path` objects in OCS
    coordinates. Elevation and offset is applied to all vertices.

    .. versionadded:: 1.1

    """
    elevation = hatch.dxf.elevation.z
    for boundary in hatch.paths:
        p = from_hatch_boundary_path(boundary, elevation=elevation, offset=offset)
        if p.has_sub_paths:
            yield from p.sub_paths()
        else:
            yield p


def from_hatch_boundary_path(
    boundary: AbstractBoundaryPath,
    ocs: Optional[OCS] = None,
    elevation: float = 0,
    offset: Vec3 = NULLVEC,  # ocs offset!
) -> Path:
    """Returns a :class:`Path` object from a :class:`~ezdxf.entities.Hatch`
    polyline- or edge path.
    """
    if isinstance(boundary, EdgePath):
        p = from_hatch_edge_path(boundary, ocs, elevation)
    elif isinstance(boundary, PolylinePath):
        p = from_hatch_polyline_path(boundary, ocs, elevation)
    else:
        raise TypeError(type(boundary))

    if offset and ocs is not None:  # only for MPOLYGON
        # assume offset is in OCS
        offset = ocs.to_wcs(offset.replace(z=elevation))
        p = p.transform(Matrix44.translate(offset.x, offset.y, offset.z))

    # attach path type information
    p.user_data = const.BoundaryPathState.from_flags(boundary.path_type_flags)
    return p


def from_hatch_polyline_path(
    polyline: PolylinePath, ocs: Optional[OCS] = None, elevation: float = 0
) -> Path:
    """Returns a :class:`Path` object from a :class:`~ezdxf.entities.Hatch`
    polyline path.
    """
    path = Path()
    tools.add_2d_polyline(
        path,
        polyline.vertices,  # list[(x, y, bulge)]
        close=polyline.is_closed,
        ocs=ocs or OCS(),
        elevation=elevation,
    )
    return path


def from_hatch_edge_path(
    edges: EdgePath,
    ocs: Optional[OCS] = None,
    elevation: float = 0,
) -> Path:
    """Returns a :class:`Path` object from a :class:`~ezdxf.entities.Hatch`
    edge path.

    """

    def line(edge: LineEdge):
        start = wcs(edge.start)
        end = wcs(edge.end)
        segment = Path(start)
        segment.line_to(end)
        return segment

    def arc(edge: ArcEdge):
        x, y, *_ = edge.center
        # from_arc() requires OCS data:
        # Note: clockwise oriented arcs are converted to counter
        # clockwise arcs at the loading stage!
        # See: ezdxf.entities.boundary_paths.ArcEdge.load_tags()
        ellipse = ConstructionEllipse.from_arc(
            center=(x, y, elevation),
            radius=edge.radius,
            extrusion=extrusion,
            start_angle=edge.start_angle,
            end_angle=edge.end_angle,
        )
        segment = Path()
        tools.add_ellipse(segment, ellipse, reset=True)
        return segment

    def ellipse(edge: EllipseEdge):
        ocs_ellipse = edge.construction_tool()
        # ConstructionEllipse has WCS representation:
        # Note: clockwise oriented ellipses are converted to counter
        # clockwise ellipses at the loading stage!
        # See: ezdxf.entities.boundary_paths.EllipseEdge.load_tags()
        ellipse = ConstructionEllipse(
            center=wcs(ocs_ellipse.center.replace(z=elevation)),
            major_axis=wcs_tangent(ocs_ellipse.major_axis),
            ratio=ocs_ellipse.ratio,
            extrusion=extrusion,
            start_param=ocs_ellipse.start_param,
            end_param=ocs_ellipse.end_param,
        )
        segment = Path()
        tools.add_ellipse(segment, ellipse, reset=True)
        return segment

    def spline(edge: SplineEdge):
        control_points = [wcs(p) for p in edge.control_points]
        if len(control_points) == 0:
            fit_points = [wcs(p) for p in edge.fit_points]
            if len(fit_points):
                bspline = from_fit_points(edge, fit_points)
            else:
                # No control points and no fit points:
                # DXF structure error
                return
        else:
            bspline = from_control_points(edge, control_points)
        segment = Path()
        tools.add_spline(segment, bspline, reset=True)
        return segment

    def from_fit_points(edge: SplineEdge, fit_points):
        tangents = None
        if edge.start_tangent and edge.end_tangent:
            tangents = (
                wcs_tangent(edge.start_tangent),
                wcs_tangent(edge.end_tangent),
            )
        return fit_points_to_cad_cv(  # only a degree of 3 is supported
            fit_points,
            tangents=tangents,
        )

    def from_control_points(edge: SplineEdge, control_points):
        return BSpline(
            control_points=control_points,
            order=edge.degree + 1,
            knots=edge.knot_values,
            weights=edge.weights if edge.weights else None,
        )

    def wcs(vertex: UVec) -> Vec3:
        return _wcs(Vec3(vertex[0], vertex[1], elevation))

    def wcs_tangent(vertex: UVec) -> Vec3:
        return _wcs(Vec3(vertex[0], vertex[1], 0))

    def _wcs(vec3: Vec3) -> Vec3:
        if ocs and ocs.transform:
            return ocs.to_wcs(vec3)
        else:
            return vec3

    extrusion = ocs.uz if ocs else Z_AXIS
    path = Path()
    loop: Optional[Path] = None
    for edge in edges:
        next_segment: Optional[Path] = None
        if isinstance(edge, LineEdge):
            next_segment = line(edge)
        elif isinstance(edge, ArcEdge):
            if abs(edge.radius) > ABS_TOL:
                next_segment = arc(edge)
        elif isinstance(edge, EllipseEdge):
            if not Vec2(edge.major_axis).is_null:
                next_segment = ellipse(edge)
        elif isinstance(edge, SplineEdge):
            next_segment = spline(edge)
        else:
            raise TypeError(type(edge))

        if next_segment is None:
            continue

        if loop is None:
            loop = next_segment
            continue

        if loop.end.isclose(next_segment.start):
            # end of current loop connects to the start of the next segment
            loop.append_path(next_segment)
        elif loop.end.isclose(next_segment.end):
            # end of current loop connects to the end of the next segment
            loop.append_path(next_segment.reversed())
        elif loop.start.isclose(next_segment.end):
            # start of the current loop connects to the end of the next segment
            next_segment.append_path(loop)
            loop = next_segment
        elif loop.start.isclose(next_segment.start):
            # start of the current loop connects to the start of the next segment
            loop = loop.reversed()
            loop.append_path(next_segment)  # type: ignore
        else:  # gap between current loop and next segment
            if loop.is_closed:  # start a new loop
                path.extend_multi_path(loop)
                loop = next_segment  # start a new loop
            # behavior changed in version v0.18 based on issue #706:
            else:  # close the gap by a straight line and append the segment
                loop.append_path(next_segment)  # type: ignore

    if loop is not None:
        loop.close()
        path.extend_multi_path(loop)
    return path  # multi path


def from_vertices(vertices: Iterable[UVec], close=False) -> Path:
    """Returns a :class:`Path` object from the given `vertices`."""
    _vertices = Vec3.list(vertices)
    if len(_vertices) < 2:
        return Path()
    path = Path(start=_vertices[0])
    for vertex in _vertices[1:]:
        if not path.end.isclose(vertex):
            path.line_to(vertex)
    if close:
        path.close()
    return path


def to_lwpolylines(
    paths: Iterable[Path],
    *,
    distance: float = MAX_DISTANCE,
    segments: int = MIN_SEGMENTS,
    extrusion: UVec = Z_AXIS,
    dxfattribs=None,
) -> Iterator[LWPolyline]:
    """Convert the given `paths` into :class:`~ezdxf.entities.LWPolyline`
    entities.
    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector. The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        extrusion: extrusion vector for all paths
        dxfattribs: additional DXF attribs

    Returns:
        iterable of :class:`~ezdxf.entities.LWPolyline` objects

    """
    if isinstance(paths, Path):
        paths = [paths]
    else:
        paths = list(paths)
    if len(paths) == 0:
        return []
    extrusion = Vec3(extrusion)
    reference_point = Vec3(paths[0].start)
    dxfattribs = dict(dxfattribs or {})
    if not Z_AXIS.isclose(extrusion):
        ocs, elevation = _get_ocs(extrusion, reference_point)
        paths = tools.transform_paths_to_ocs(paths, ocs)
        dxfattribs["elevation"] = elevation
        dxfattribs["extrusion"] = extrusion
    elif reference_point.z != 0:
        dxfattribs["elevation"] = reference_point.z

    for path in tools.single_paths(paths):  # type: ignore
        if len(path) > 0:
            p = LWPolyline.new(dxfattribs=dxfattribs)
            p.append_points(path.flattening(distance, segments), format="xy")  # type: ignore
            yield p


def _get_ocs(extrusion: Vec3, reference_point: Vec3) -> tuple[OCS, float]:
    ocs = OCS(extrusion)
    elevation = ocs.from_wcs(reference_point).z  # type: ignore
    return ocs, elevation


def to_polylines2d(
    paths: Iterable[Path],
    *,
    distance: float = MAX_DISTANCE,
    segments: int = MIN_SEGMENTS,
    extrusion: UVec = Z_AXIS,
    dxfattribs=None,
) -> Iterator[Polyline]:
    """Convert the given `paths` into 2D :class:`~ezdxf.entities.Polyline`
    entities.
    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector. The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        extrusion: extrusion vector for all paths
        dxfattribs: additional DXF attribs

    Returns:
        iterable of 2D :class:`~ezdxf.entities.Polyline` objects

    """
    if isinstance(paths, Path):
        paths = [paths]
    else:
        paths = list(paths)
    if len(paths) == 0:
        return []
    extrusion = Vec3(extrusion)
    reference_point = Vec3(paths[0].start)
    dxfattribs = dict(dxfattribs or {})
    if not Z_AXIS.isclose(extrusion):
        ocs, elevation = _get_ocs(extrusion, reference_point)
        paths = tools.transform_paths_to_ocs(paths, ocs)
        dxfattribs["elevation"] = Vec3(0, 0, elevation)
        dxfattribs["extrusion"] = extrusion
    elif reference_point.z != 0:
        dxfattribs["elevation"] = Vec3(0, 0, reference_point.z)

    for path in tools.single_paths(paths):  # type: ignore
        if len(path) > 0:
            p = Polyline.new(dxfattribs=dxfattribs)
            p.append_vertices(path.flattening(distance, segments))
            p.new_seqend()
            yield p


def to_hatches(
    paths: Iterable[Path],
    *,
    edge_path: bool = True,
    distance: float = MAX_DISTANCE,
    segments: int = MIN_SEGMENTS,
    g1_tol: float = G1_TOL,
    extrusion: UVec = Z_AXIS,
    dxfattribs=None,
) -> Iterator[Hatch]:
    """Convert the given `paths` into :class:`~ezdxf.entities.Hatch` entities.
    Uses LWPOLYLINE paths for boundaries without curves and edge paths, build
    of LINE and SPLINE edges, as boundary paths for boundaries including curves.
    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector. The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        paths: iterable of :class:`Path` objects
        edge_path: ``True`` for edge paths build of LINE and SPLINE edges,
            ``False`` for only LWPOLYLINE paths as boundary paths
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve to flatten LWPOLYLINE paths
        g1_tol: tolerance for G1 continuity check to separate SPLINE edges
        extrusion: extrusion vector to all paths
        dxfattribs: additional DXF attribs

    Returns:
        iterable of :class:`~ezdxf.entities.Hatch` objects

    """
    boundary_factory: BoundaryFactory
    if edge_path:
        # noinspection PyTypeChecker
        boundary_factory = partial(
            build_edge_path, distance=distance, segments=segments, g1_tol=g1_tol
        )
    else:
        # noinspection PyTypeChecker
        boundary_factory = partial(
            build_poly_path, distance=distance, segments=segments
        )

    yield from _polygon_converter(
        Hatch, paths, boundary_factory, extrusion, dxfattribs
    )


def to_mpolygons(
    paths: Iterable[Path],
    *,
    distance: float = MAX_DISTANCE,
    segments: int = MIN_SEGMENTS,
    extrusion: UVec = Z_AXIS,
    dxfattribs=None,
) -> Iterator[MPolygon]:
    """Convert the given `paths` into :class:`~ezdxf.entities.MPolygon` entities.
    In contrast to HATCH, MPOLYGON supports only polyline boundary paths.
    All curves will be approximated.

    The `extrusion` vector is applied to all paths, all vertices are projected
    onto the plane normal to this extrusion vector. The default extrusion vector
    is the WCS z-axis. The plane elevation is the distance from the WCS origin
    to the start point of the first path.

    Args:
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve to flatten LWPOLYLINE paths
        extrusion: extrusion vector to all paths
        dxfattribs: additional DXF attribs

    Returns:
        iterable of :class:`~ezdxf.entities.MPolygon` objects

    """
    # noinspection PyTypeChecker
    boundary_factory: BoundaryFactory = partial(
        build_poly_path, distance=distance, segments=segments
    )
    dxfattribs = dict(dxfattribs or {})
    dxfattribs.setdefault("fill_color", const.BYLAYER)  # type: ignore

    yield from _polygon_converter(
        MPolygon, paths, boundary_factory, extrusion, dxfattribs
    )


def build_edge_path(
    boundaries: BoundaryPaths,
    path: Path,
    flags: int,
    distance: float,
    segments: int,
    g1_tol: float,
):
    if path.has_curves:  # Edge path with LINE and SPLINE edges
        edge_path = boundaries.add_edge_path(flags)
        for edge in to_bsplines_and_vertices(path, g1_tol=g1_tol):
            if isinstance(edge, BSpline):
                edge_path.add_spline(
                    control_points=edge.control_points,
                    degree=edge.degree,
                    knot_values=edge.knots(),
                )
            else:  # add LINE edges
                prev = edge[0]
                for p in edge[1:]:
                    edge_path.add_line(prev, p)
                    prev = p
    else:  # Polyline boundary path
        boundaries.add_polyline_path(
            Vec2.generate(path.flattening(distance, segments)), flags=flags
        )


def build_poly_path(
    boundaries: BoundaryPaths,
    path: Path,
    flags: int,
    distance: float,
    segments: int,
):
    boundaries.add_polyline_path(
        # Vec2 removes the z-axis, which would be interpreted as bulge value!
        Vec2.generate(path.flattening(distance, segments)),
        flags=flags,
    )


def _polygon_converter(
    cls: Type[TPolygon],
    paths: Iterable[Path],
    add_boundary: BoundaryFactory,
    extrusion: UVec = Z_AXIS,
    dxfattribs=None,
) -> Iterator[TPolygon]:
    if isinstance(paths, Path):
        paths = [paths]
    else:
        paths = list(paths)
    if len(paths) == 0:
        return []

    extrusion = Vec3(extrusion)
    reference_point = paths[0].start
    _dxfattribs: dict = dict(dxfattribs or {})
    if not Z_AXIS.isclose(extrusion):
        ocs, elevation = _get_ocs(extrusion, reference_point)
        paths = tools.transform_paths_to_ocs(paths, ocs)
        _dxfattribs["elevation"] = Vec3(0, 0, elevation)
        _dxfattribs["extrusion"] = extrusion
    elif reference_point.z != 0:
        _dxfattribs["elevation"] = Vec3(0, 0, reference_point.z)
    _dxfattribs.setdefault("solid_fill", 1)
    _dxfattribs.setdefault("pattern_name", "SOLID")
    _dxfattribs.setdefault("color", const.BYLAYER)

    for group in group_paths(tools.single_paths(paths)):
        if len(group) == 0:
            continue
        polygon = cls.new(dxfattribs=_dxfattribs)
        boundaries = polygon.paths
        external = group[0]
        external.close()
        add_boundary(boundaries, external, 1)  # type: ignore
        for hole in group[1:]:
            hole.close()
            add_boundary(boundaries, hole, 0)  # type: ignore
        yield polygon


def to_polylines3d(
    paths: Iterable[Path],
    *,
    distance: float = MAX_DISTANCE,
    segments: int = MIN_SEGMENTS,
    dxfattribs=None,
) -> Iterator[Polyline]:
    """Convert the given `paths` into 3D :class:`~ezdxf.entities.Polyline`
    entities.

    Args:
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        dxfattribs: additional DXF attribs

    Returns:
        iterable of 3D :class:`~ezdxf.entities.Polyline` objects

    """
    if isinstance(paths, Path):
        paths = [paths]

    dxfattribs = dict(dxfattribs or {})
    dxfattribs["flags"] = const.POLYLINE_3D_POLYLINE
    for path in tools.single_paths(paths):  # type: ignore
        if len(path) > 0:
            p = Polyline.new(dxfattribs=dxfattribs)
            p.append_vertices(path.flattening(distance, segments))
            p.new_seqend()
            yield p


def to_lines(
    paths: Iterable[Path],
    *,
    distance: float = MAX_DISTANCE,
    segments: int = MIN_SEGMENTS,
    dxfattribs=None,
) -> Iterator[Line]:
    """Convert the given `paths` into :class:`~ezdxf.entities.Line` entities.

    Args:
        paths: iterable of :class:`Path` objects
        distance:  maximum distance, see :meth:`Path.flattening`
        segments: minimum segment count per Bézier curve
        dxfattribs: additional DXF attribs

    Returns:
        iterable of :class:`~ezdxf.entities.Line` objects

    """
    if isinstance(paths, Path):
        paths = [paths]
    dxfattribs = dict(dxfattribs or {})
    prev_vertex = None
    for path in tools.single_paths(paths):  # type: ignore
        if len(path) == 0:
            continue
        for vertex in path.flattening(distance, segments):
            if prev_vertex is None:
                prev_vertex = vertex
                continue
            dxfattribs["start"] = prev_vertex
            dxfattribs["end"] = vertex
            yield Line.new(dxfattribs=dxfattribs)
            prev_vertex = vertex
        prev_vertex = None


PathParts: TypeAlias = Union[BSpline, List[Vec3]]


def to_bsplines_and_vertices(path: Path, g1_tol: float = G1_TOL) -> Iterator[PathParts]:
    """Convert a :class:`Path` object into multiple cubic B-splines and
    polylines as lists of vertices. Breaks adjacent Bèzier without G1
    continuity into separated B-splines.

    Args:
        path: :class:`Path` objects
        g1_tol: tolerance for G1 continuity check

    Returns:
        :class:`~ezdxf.math.BSpline` and lists of :class:`~ezdxf.math.Vec3`

    """
    from ezdxf.math import bezier_to_bspline

    def to_vertices():
        points = [polyline[0][0]]
        for line in polyline:
            points.append(line[1])
        return points

    def to_bspline():
        b1 = bezier[0]
        _g1_continuity_curves = [b1]
        for b2 in bezier[1:]:
            if have_bezier_curves_g1_continuity(b1, b2, g1_tol):
                _g1_continuity_curves.append(b2)
            else:
                yield bezier_to_bspline(_g1_continuity_curves)
                _g1_continuity_curves = [b2]
            b1 = b2

        if _g1_continuity_curves:
            yield bezier_to_bspline(_g1_continuity_curves)

    curves = []
    for path in tools.single_paths([path]):  # type: ignore
        prev = path.start
        for cmd in path:
            if cmd.type == Command.CURVE3_TO:
                curve = Bezier3P([prev, cmd.ctrl, cmd.end])  # type: ignore
            elif cmd.type == Command.CURVE4_TO:
                curve = Bezier4P([prev, cmd.ctrl1, cmd.ctrl2, cmd.end])  # type: ignore
            elif cmd.type == Command.LINE_TO:
                curve = (prev, cmd.end)
            else:
                raise ValueError
            curves.append(curve)
            prev = cmd.end

    bezier: list = []
    polyline: list = []
    for curve in curves:
        if isinstance(curve, tuple):
            if bezier:
                yield from to_bspline()
                bezier.clear()
            polyline.append(curve)
        else:
            if polyline:
                yield to_vertices()
                polyline.clear()
            bezier.append(curve)

    if bezier:
        yield from to_bspline()
    if polyline:
        yield to_vertices()


def to_splines_and_polylines(
    paths: Iterable[Path],
    *,
    g1_tol: float = G1_TOL,
    dxfattribs=None,
) -> Iterator[Union[Spline, Polyline]]:
    """Convert the given `paths` into :class:`~ezdxf.entities.Spline` and 3D
    :class:`~ezdxf.entities.Polyline` entities.

    Args:
        paths: iterable of :class:`Path` objects
        g1_tol: tolerance for G1 continuity check
        dxfattribs: additional DXF attribs

    Returns:
        iterable of :class:`~ezdxf.entities.Line` objects

    """
    if isinstance(paths, Path):
        paths = [paths]
    dxfattribs = dict(dxfattribs or {})

    for path in tools.single_paths(paths):  # type: ignore
        for data in to_bsplines_and_vertices(path, g1_tol):
            if isinstance(data, BSpline):
                spline = Spline.new(dxfattribs=dxfattribs)
                spline.apply_construction_tool(data)
                yield spline
            else:
                attribs = dict(dxfattribs)
                attribs["flags"] = const.POLYLINE_3D_POLYLINE
                polyline = Polyline.new(dxfattribs=dxfattribs)
                polyline.append_vertices(data)
                polyline.new_seqend()
                yield polyline
