# -*- coding: utf-8 -*-
"""
This file is part of FElupe.

FElupe is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

FElupe is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with FElupe.  If not, see <http://www.gnu.org/licenses/>.
"""

import numpy as np
from scipy.interpolate import griddata

from ..math import rotation_matrix, transpose
from ._helpers import mesh_or_data


@mesh_or_data
def expand(points, cells, cell_type, n=11, z=1, axis=-1, expand_dim=True):
    """Expand a 0d-Point to a 1d-Line, a 1d-Line to a 2d-Quad or a 2d-Quad to a
    3d-Hexahedron Mesh.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    n : int, optional
        Number of n-point repetitions or (n-1)-cell repetitions, default is 11. Must be
        greater or equal 0.
    z : float or ndarray, optional
        Total expansion as float (edge length in expand direction is
        ``z / (n - 1)``), default is 1. Optionally, if an array is passed these entries
        are taken as expansion and ``n`` is ignored.
    axis : int, optional
        Axis of expansion (default is -1).
    expand_dim : bool, optional
        Expand the dimension of the point coordinates (default is True).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    Expand a rectangle to a cube.

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> rect = fem.Rectangle(n=4)
       >>> cube = fem.mesh.expand(rect, n=7, z=2)
       >>>
       >>> cube.plot().show()

    >>> cube
    <felupe Mesh object>
      Number of points: 112
      Number of cells:
        hexahedron: 54

    See Also
    --------
    felupe.Mesh.expand : Expand a 0d-Point to a 1d-Line, a 1d-Line to a 2d-Quad or a
        2d-Quad to a 3d-Hexahedron Mesh.
    """

    thickness = z

    # ensure points, cells as ndarray
    points = np.array(points)
    cells = np.array(cells)

    # get dimension of points array
    dim = points.shape[1]

    # init new padded points array
    dim_new = dim
    if expand_dim:
        dim_new += 1

    points_new = np.pad(points, ((0, 0), (0, dim_new - dim)))[np.newaxis, ...]
    cells_new = cells
    cell_type_new = cell_type

    # set new cell-type and the appropriate slice
    if n > 1:
        cell_type_new, sl = {
            "vertex": ("line", slice(None, None, None)),
            "line": ("quad", slice(None, None, -1)),
            "quad": ("hexahedron", slice(None, None, None)),
            "quad9": ("hexahedron27", slice(None, None, None)),
        }[cell_type]

        if cell_type_new == "hexahedron27":
            n = 2 * n - 1

        if np.isscalar(thickness):
            points_thickness = np.linspace(0, thickness, n)
        else:

            points_thickness = np.array(thickness)
            if cell_type_new == "hexahedron27":

                points_thickness = np.vstack(
                    [
                        points_thickness,
                        points_thickness + np.diff(points_thickness, append=np.nan) / 2,
                    ]
                ).T.ravel()[:-1]

        n = len(points_thickness)

        # init zero vector of input dimension
        layers = np.zeros((n, dim_new))
        layers[:, axis] = points_thickness

        points_new = points_new + layers[:, np.newaxis, ...]

        # generate new cells array
        cells_new = (
            cells[np.newaxis, ...]
            + len(points) * np.arange(n)[..., np.newaxis, np.newaxis]
        )

        if cell_type_new == "hexahedron27":
            verts = [0, 1, 2, 3, 18, 19, 20, 21]
            edges = [4, 5, 6, 7, 22, 23, 24, 25, 9, 10, 11, 12]
            faces = [16, 14, 13, 15, 8, 26]
            volume = [17]

            start = cells_new[:-2:2]
            middle = cells_new[1:-1:2]
            end = cells_new[2::2]

            cells_new = np.concatenate([start, middle, end], axis=-1)[
                ..., [*verts, *edges, *faces, *volume]
            ]

        else:
            start = cells_new[:-1]
            end = cells_new[1:, ..., sl]

            cells_new = np.concatenate([start, end], axis=-1)

        # expand vertex point to line in first direction
        if cell_type_new == "line":
            points_new = points_new[..., 1:]

    return (
        points_new.reshape(-1, points_new.shape[-1]),
        cells_new.reshape(-1, cells_new.shape[-1]),
        cell_type_new,
    )


def fill_between(mesh, other_mesh, n=11):
    """Fill a 2d-Quad Mesh between two 1d-Line Meshes, embedded in 2d-space, or a
    3d-Hexahedron Mesh between two 2d-Quad Meshes, embedded in 3d-space, by expansion.
    Both meshes must have equal number of points and cells. The cells-array is taken
    from the first mesh.

    Parameters
    ----------
    mesh : felupe.Mesh
        The base line- or quad-mesh.
    other_mesh : felupe.Mesh
        The other line- or quad-mesh.
    n : int or ndarray
        Number of n-point repetitions or (n-1)-cell repetitions,
        (default is 11). If an array is given, then its values are used for the
        relative positions in a reference configuration (-1, 1) between the two meshes.

    Returns
    -------
    mesh : felupe.Mesh
        The expanded mesh.

    Examples
    --------
    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> inner = fem.mesh.revolve(fem.Point(1)).expand(z=0.4).translate(0.2, axis=2)
       >>> outer = fem.mesh.revolve(fem.Point(2), phi=160).rotate(
       ...     axis=2, angle_deg=20
       ... ).expand(z=1.2)
       >>> mesh = fem.mesh.fill_between(inner, outer, n=6)
       >>>
       >>> mesh.plot().show()

    See Also
    --------
    felupe.Mesh.fill_between : Fill a 2d-Quad Mesh between two 1d-Line Meshes, embedded
        in 2d-space, or a 3d-Hexahedron Mesh between two 2d-Quad Meshes, embedded in
        3d-space, by expansion.

    """

    if not hasattr(n, "__len__"):
        n = np.linspace(-1, 1, n)

    sections = []
    for bottom, top in zip(mesh.points, other_mesh.points):
        sections.append(griddata(points=[-1, 1], values=np.vstack([bottom, top]), xi=n))

    new_mesh = mesh.copy()
    new_mesh.points = new_mesh.points[:, : mesh.points.shape[1] - 1]

    new_mesh = new_mesh.expand(n=len(n))
    new_mesh.points[:] = transpose(sections).reshape(new_mesh.points.shape)

    return new_mesh


@mesh_or_data
def rotate(points, cells, cell_type, angle_deg, axis, center=None, mask=None):
    """Rotate a Mesh.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    angle_deg : int
        Rotation angle in degree.
    axis : int
        Rotation axis.
    center : list or ndarray or None, optional
        Center point coordinates (default is None).
    mask : ndarray or None, optional
        A boolean mask to select points which are rotated (default is None).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    Rotate a rectangle in the xy-plane by 35 degree.

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> rect = fem.Rectangle(b=(3, 1), n=(10, 4))
       >>> mesh = fem.mesh.rotate(rect, angle_deg=35, axis=2, center=[1.5, 0.5])
       >>> mesh.plot().show()

    >>> mesh
    <felupe Mesh object>
      Number of points: 40
      Number of cells:
        quad: 27

    See Also
    --------
    felupe.Mesh.rotate : Rotate a Mesh.
    """

    points = np.array(points)
    dim = points.shape[1]

    if center is None:
        center = np.zeros(dim)
    else:
        center = np.array(center)
    center = center.reshape(1, -1)

    if mask is None:
        mask = slice(None)

    points_rotated = (
        rotation_matrix(angle_deg, dim, axis) @ (points - center).T
    ).T + center

    points_new = points.copy()
    points_new[mask] = points_rotated[mask]

    return points_new, cells, cell_type


@mesh_or_data
def revolve(points, cells, cell_type, n=11, phi=180, axis=0, expand_dim=True):
    """Revolve a 0d-Point to a 1d-Line, a 1d-Line to 2d-Quad or a 2d-Quad to a
    3d-Hexahedron Mesh.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    n : int, optional
        Number of n-point revolutions (or (n-1) cell revolutions),
        default is 11.
    phi : float or ndarray, optional
        Revolution angle in degree (default is 180).
    axis : int, optional
        Revolution axis (default is 0).
    expand_dim : bool, optional
        Expand the dimension of the point coordinates (default is True).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : list or ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    Revolve a cylinder from a rectangle.

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> rect = fem.Rectangle(a=(0, 4), b=(3, 5), n=(10, 4))
       >>> mesh = fem.mesh.revolve(rect, n=11, phi=180, axis=0)
       >>> mesh.plot().show()

    >>> mesh
    <felupe Mesh object>
      Number of points: 440
      Number of cells:
        hexahedron: 270

    See Also
    --------
    felupe.Mesh.revolve : Revolve a 2d-Quad to a 3d-Hexahedron Mesh.
    """

    points = np.array(points)
    cells = np.array(cells)

    dim = points.shape[1]

    # set new cell-type and the appropriate slice
    cell_type_new, sl = {
        "vertex": ("line", slice(None, None, None)),
        "line": ("quad", slice(None, None, -1)),
        "quad": ("hexahedron", slice(None, None, None)),
        "quad9": ("hexahedron27", slice(None, None, None)),
    }[cell_type]

    if cell_type_new == "hexahedron27":
        n = 2 * n - 1

    if np.isscalar(phi):
        points_phi = np.linspace(0, phi, n)
    else:
        points_phi = np.array(phi)

        if cell_type_new == "hexahedron27":
            points_phi = np.vstack(
                [points_phi, points_phi + np.diff(points_phi, append=np.nan) / 2]
            ).T.ravel()[:-1]

    n = len(points_phi)
    dim_new = dim

    if expand_dim:
        dim_new = dim + 1

    p = np.pad(points, ((0, 0), (0, dim_new - dim)))
    R = rotation_matrix

    points_new = np.vstack(
        [(R(angle, dim_new, axis=axis) @ p.T).T for angle in points_phi]
    )

    c = [cells + len(p) * a for a in np.arange(n)]

    if points_phi[-1] == 360:
        c[-1] = c[0]
        points_new = points_new[: len(points_new) - len(points)]

    if cell_type_new == "hexahedron27":
        one = [0, 1, 2, 3, 18, 19, 20, 21]
        two = [4, 5, 6, 7, 22, 23, 24, 25]
        three = [9, 10, 11, 12, 16, 14, 13, 15, 8, 26, 17]

        cells_new = np.vstack(
            [np.hstack((r, s, t)) for r, s, t in zip(c[:-2:2], c[1:-1:2], c[2::2])]
        )[:, [*one, *two, *three]]

    else:
        cells_new = np.vstack([np.hstack((r, s[:, sl])) for r, s in zip(c[:-1], c[1:])])

    return points_new, cells_new, cell_type_new


@mesh_or_data
def merge_duplicate_points(points, cells, cell_type, decimals=None):
    """Merge duplicate points and update cells of a Mesh.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    decimals : int or None, optional
        Number of decimals for point coordinate comparison (default is None).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : list or ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Notes
    -----
    ..  warning::
        This function re-sorts points.

    ..  note::
        This function does not merge duplicate cells.

    Examples
    --------
    Two quad meshes to be merged overlap some points. Merge these duplicated
    points and update the cells.

    .. pyvista-plot::
       :context:
       :force_static:

       >>> import felupe as fem
       >>>
       >>> rect1 = fem.Rectangle(n=11)
       >>> rect2 = fem.Rectangle(a=(0.9, 0), b=(1.9, 1), n=11)
       >>>
       >>> container = fem.MeshContainer([rect1, rect2])
       >>> stack = fem.mesh.stack(container.meshes)
       >>> mesh = fem.mesh.merge_duplicate_points(stack)
       >>>
       >>> mesh.plot(opacity=0.6).show()

    Each mesh contains 121 points and 100 cells.

    ..  pyvista-plot::
        :context:

        >>> rect1
        <felupe Mesh object>
          Number of points: 121
          Number of cells:
            quad: 100

        >>> rect2
        <felupe Mesh object>
          Number of points: 121
          Number of cells:
            quad: 100

    These two meshes are now stored in a
    :class:`~felupe.MeshContainer`.

    ..  pyvista-plot::
        :context:

        >>> container
        <felupe mesh container object>
          Number of points: 242
          Number of cells:
            quad: 100
            quad: 100

    The meshes of the mesh container are :func:`stacked <felupe.mesh.stack>`.

    ..  pyvista-plot::
        :context:

        >>> stack
        <felupe Mesh object>
          Number of points: 242
          Number of cells:
            quad: 200

    After merging the duplicated points and cells, the number of points is reduced but
    the number of cells is unchanged.

    ..  pyvista-plot::
        :context:

        >>> mesh
        <felupe Mesh object>
          Number of points: 220
          Number of cells:
            quad: 200

    ..  note::
        The :class:`~felupe.MeshContainer` may be directly created with ``merge=True``.
        This enforces :func:`~felupe.mesh.merge_duplicate_points` for the shared points
        array of the container.

    See Also
    --------
    felupe.Mesh.merge_duplicate_points : Merge duplicated points and update cells of a
        Mesh.
    felupe.MeshContainer : A container which operates on a list of meshes with identical
        dimensions.
    """

    if decimals is None:
        points_rounded = points
    else:
        points_rounded = np.round(points, decimals)

    points_new, inverse = np.unique(
        points_rounded,
        return_index=False,
        return_inverse=True,
        return_counts=False,
        axis=0,
    )

    original = np.arange(len(points))

    mask = inverse.ravel() != original
    find = original[mask]
    replace = inverse[mask]

    cells_new = cells.copy()

    for i, j in zip(find, replace):
        cells_new[cells == i] = j

    return points_new, cells_new, cell_type


@mesh_or_data
def merge_duplicate_cells(points, cells, cell_type):
    """Merge duplicate cells of a Mesh.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.

    Returns
    -------
    points : ndarray
        Point coordinates.
    cells : list or ndarray
        Cells with merged duplicate cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Notes
    -----
    ..  warning::
        This function re-sorts cells.

    ..  note::
        This function does not merge duplicate points.

    Examples
    --------
    Two quad meshes to be merged overlap some cells. Merge these duplicated
    points and update the cells.

    ..  pyvista-plot::
        :context:
        :force_static:

        >>> import felupe as fem
        >>>
        >>> rect1 = fem.Rectangle(n=11)
        >>> rect2 = fem.Rectangle(a=(0.9, 0), b=(1.9, 1), n=11)
        >>>
        >>> container = fem.MeshContainer([rect1, rect2])
        >>> stack = fem.mesh.stack(container.meshes)
        >>> mesh = fem.mesh.merge_duplicate_points(stack)
        >>>
        >>> mesh.plot(opacity=0.6).show()

    Each mesh contains 121 points and 100 cells.

    ..  pyvista-plot::
        :context:

        >>> rect1
        <felupe Mesh object>
          Number of points: 121
          Number of cells:
            quad: 100

        >>> rect2
        <felupe Mesh object>
            Number of points: 121
            Number of cells:
            quad: 100

    These two meshes are now stored in a :class:`~felupe.MeshContainer`.

    ..  pyvista-plot::
        :context:

        >>> container
        <felupe mesh container object>
        Number of points: 242
        Number of cells:
            quad: 100
            quad: 100

    The meshes of the mesh container are :func:`stacked <felupe.mesh.stack>`.

    ..  pyvista-plot::
        :context:

        >>> stack
        <felupe Mesh object>
        Number of points: 242
        Number of cells:
            quad: 200

    After merging the duplicated points and cells, the number of points is reduced but
    the number of cells is unchanged.

    ..  pyvista-plot::
        :context:

        >>> mesh
        <felupe Mesh object>
        Number of points: 220
        Number of cells:
            quad: 200

    ..  note::
        The :class:`~felupe.MeshContainer` may be directly created with ``merge=True``.
        This enforces :func:`~felupe.mesh.merge_duplicate_points` for the shared points
        array of the container.

    The duplicate cells are merged in a second step.

    ..  pyvista-plot::
        :context:

        >>> merged = fem.mesh.merge_duplicate_cells(mesh)
        >>> merged
        <felupe Mesh object>
        Number of points: 220
        Number of cells:
            quad: 190

    See Also
    --------
    felupe.Mesh.merge_duplicate_points : Merge duplicate points of a Mesh.
    felupe.Mesh.merge_duplicate_cells : Merge duplicate cells of a Mesh.
    felupe.MeshContainer : A container which operates on a list of meshes with identical
        dimensions.
    """

    return points, np.unique(cells, axis=0), cell_type


@mesh_or_data
def translate(points, cells, cell_type, move, axis):
    """Translate (move) a Mesh along a given axis.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    move : float
        Translation along given axis.
    axis : int
        Translation axis.

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    >>> import felupe as fem
    >>>
    >>> mesh = fem.Circle(n=6)
    >>> mesh.points.min(axis=0), mesh.points.max(axis=0)
    (array([-1., -1.]), array([1., 1.]))

    >>> translated = fem.mesh.translate(mesh, 0.3, axis=1)
    >>> translated.points.min(axis=0), translated.points.max(axis=0)
    (array([-1. , -0.7]), array([1. , 1.3]))

    See Also
    --------
    felupe.Mesh.translate : Translate (move) a Mesh along a given axis.
    """

    points_new = np.array(points)
    points_new[:, axis] += move

    return points_new, cells, cell_type


@mesh_or_data
def flip(points, cells, cell_type, mask=None):
    r"""Ensure positive cell volumes for `tria`, `tetra`, `quad` and `hexahedron` cell
    types.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    mask: list, ndarray or None, optional
        Boolean mask for selected cells to flip (default is None). If None, all cells
        are selected to be flipped.

    Returns
    -------
    points : ndarray
        Point coordinates.
    cells : ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    A quad mesh with negative cell volumes occurs if one coordinate axis is multiplied
    by -1. The error pops up if a region is created with this mesh.

    >>> import numpy as np
    >>> import felupe as fem
    >>>
    >>> mesh = fem.Rectangle(n=3)
    >>> mesh.update(points=mesh.points * np.array([[-1, 1]]))
    >>> region = fem.RegionQuad(mesh)

    The sum of the differential volumes :math:`V = \sum_c \sum_q dV_{qc}` is evaluated
    to -1.0.

    >>> region.dV.sum()
    -1.0

    Let's try to fix the mesh.

    >>> mesh.cells
    array([[0, 1, 4, 3],
           [1, 2, 5, 4],
           [3, 4, 7, 6],
           [4, 5, 8, 7]])

    The cells array is rearranged to ensure positive cell volumes.

    >>> mesh_fixed = fem.mesh.flip(mesh)
    >>> mesh_fixed.cells
    array([[3, 4, 1, 0],
           [4, 5, 2, 1],
           [6, 7, 4, 3],
           [7, 8, 5, 4]])

    A region now correctly evaluates the total volume of the mesh to 1.0.

    >>> region_fixed = fem.RegionQuad(mesh_fixed)
    >>> region_fixed.dV.sum()
    1.0

    See Also
    --------
    felupe.Mesh.flip : Ensure positive cell volumes for `tria`, `tetra`, `quad` and
        `hexahedron` cell types.

    """

    if mask is None:
        mask = slice(None)
    else:
        mask = np.where(mask)[0].reshape(-1, 1)

    faces_to_flip = {
        "line": ([0, 1],),
        "triangle": ([0, 1, 2],),
        "tetra": ([0, 1, 2],),
        "quad": ([0, 1, 2, 3],),
        "hexahedron": ([0, 1, 2, 3], [4, 5, 6, 7]),
    }[cell_type]

    cells_new = cells.copy()

    for face in faces_to_flip:
        cells_new[mask, face] = cells[mask, face[::-1]]

    return points, cells_new, cell_type


@mesh_or_data
def mirror(
    points, cells, cell_type, normal=[1, 0, 0], centerpoint=[0, 0, 0], axis=None
):
    """Mirror points by plane normal and ensure positive cell volumes for
    `tria`, `tetra`, `quad` and `hexahedron` cell types.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    normal: list or ndarray, optional
        Mirror-plane normal vector (default is [1, 0, 0]).
    centerpoint: list or ndarray, optional
        Center-point coordinates on the mirror plane (default is [0, 0, 0]).
    axis: int or None, optional
        Mirror axis (default is None).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> mesh = fem.Circle(sections=[0, 90, 180], n=5)
       >>> mesh.plot().show()

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> mesh = fem.Circle(sections=[0, 90, 180], n=5)
       >>> fem.mesh.mirror(mesh, normal=[0, 1, 0]).plot().show()

    See Also
    --------
    felupe.Mesh.mirror : Mirror points by plane normal and ensure positive cell volumes
        for `tria`, `tetra`, `quad` and `hexahedron` cell types.

    """

    points = np.array(points)
    cells = np.array(cells)

    dim = points.shape[1]

    # create normal vector
    if axis is not None:
        normal = np.zeros(dim)
        normal[axis] = 1
    else:
        normal = np.array(normal, dtype=float)[:dim]

        # ensure unit vector
        normal /= np.linalg.norm(normal)

    centerpoint = np.array(centerpoint, dtype=float)[:dim]

    points_new = points - np.einsum(
        "i, k, ...k -> ...i", 2 * normal, normal, (points - centerpoint)
    )

    return flip(points_new, cells, cell_type, mask=None)


def concatenate(meshes):
    """Join a sequence of meshes with identical cell types.

    Parameters
    ----------
    meshes : list of Mesh
        A list with meshes.

    Returns
    -------
    Mesh
        The joined mesh.

    Notes
    -----
    The ``points``-arrays are vertically stacked. Offsets are added to the  ``cells``-
    arrays of the meshes to refer to the original points.

    Examples
    --------
    Two quad meshes should be joined (merged) into a single mesh.

    >>> import felupe as fem
    >>>
    >>> rect1 = fem.Rectangle(n=11)
    >>> rect2 = fem.Rectangle(a=(0.9, 0), b=(1.9, 1), n=11)
    >>>
    >>> mesh = fem.mesh.concatenate([rect1, rect2])
    >>> mesh.plot(opacity=0.6).show()

    Each mesh contains 121 points and 100 cells.

    >>> rect1
    <felupe Mesh object>
      Number of points: 121
      Number of cells:
        quad: 100

    >>> rect2
    <felupe Mesh object>
      Number of points: 121
      Number of cells:
        quad: 100

    These two meshes are stored in a :class:`~felupe.Mesh`. Note that there are
    duplicate points and cells in the joined mesh.

    >>> mesh
    <felupe Mesh object>
      Number of points: 242
      Number of cells:
        quad: 200
    """

    Mesh = meshes[0].__mesh__

    points = np.vstack([mesh.points for mesh in meshes])
    offsets = np.cumsum(np.insert([mesh.npoints for mesh in meshes][:-1], 0, 0))
    cells = np.vstack(
        [int(offset) + mesh.cells for offset, mesh in zip(offsets, meshes)]
    )
    mesh = Mesh(points=points, cells=cells, cell_type=meshes[0].cell_type)

    return mesh


def stack(meshes):
    """Stack cell-blocks from meshes with identical points-array and cell-types.

    Parameters
    ----------
    meshes : list of Mesh
        A list with meshes. The ``points``-array is taken from the first mesh in the
        list.

    Returns
    -------
    Mesh
        The stacked mesh.

    Notes
    -----
    The ``points``-array is taken from the first mesh. The ``points``-arrays of all
    meshes must be identical. The ``cells``-array of the stacked mesh is created by
    a vertical stack of the ``cells``-arrays.

    Examples
    --------
    Two quad meshes with identical point arrays should be stacked into a single mesh.

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> mesh = fem.Rectangle(n=11)
       >>> rect1, rect2 = mesh.copy(), mesh.copy()
       >>> rect1.update(cells=mesh.cells[: 40])
       >>> rect2.update(cells=mesh.cells[-50:])
       >>>
       >>> mesh = fem.mesh.stack([rect1, rect2])
       >>> mesh.plot().show()

    >>> mesh
    <felupe Mesh object>
      Number of points: 121
      Number of cells:
        quad: 90

    See Also
    --------
    felupe.MeshContainer.stack : Stack cell-blocks with same cell-types into a single
        mesh.
    """

    Mesh = meshes[0].__mesh__

    points = meshes[0].points
    cells = np.vstack([mesh.cells for mesh in meshes])
    mesh = Mesh(points=points, cells=cells, cell_type=meshes[0].cell_type)

    return mesh


@mesh_or_data
def triangulate(points, cells, cell_type, mode=3):
    """Triangulate a quad or a hex mesh.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    mode: int, optional
        Choose a mode how to convert hexahedrons to tets [1]_ (default is 3).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    Use ``mode=0`` to convert a mesh of hexahedrons into tetrahedrons [1]_.

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> mesh = fem.Cube(n=6)
       >>> triangulated = fem.mesh.triangulate(mesh, mode=0)
       >>> triangulated.plot().show()

    Use ``mode=3`` to convert a mesh of hexahedrons into tetrahedrons [1]_.

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> mesh = fem.Cube(n=6)
       >>> triangulated = fem.mesh.triangulate(mesh, mode=3)
       >>> triangulated.plot().show()

    References
    ----------
    .. [1] Dompierre, J., Labbé, P., Vallet, M. G., & Camarero, R. (1999).
       How to Subdivide Pyramids, Prisms, and Hexahedra into Tetrahedra.
       IMR, 99, 195.

    See Also
    --------
    felupe.Mesh.triangulate : Triangulate a quad or a hex mesh.
    """

    if cell_type == "quad":
        # triangles out of a quad
        i = [0, 3]
        j = [1, 1]
        k = [3, 2]

        cells_new = np.dstack(
            (
                cells[:, i],
                cells[:, j],
                cells[:, k],
            )
        )

        cell_type_new = "triangle"

    elif cell_type == "hexahedron":
        # tets out of a hex
        # mode ... no. of diagional through hex-point 6.
        if mode == 0:
            i = [0, 0, 0, 0, 2]
            j = [1, 2, 2, 5, 7]
            k = [2, 7, 3, 7, 5]
            m = [5, 5, 7, 4, 6]

        elif mode == 3:
            i = [0, 0, 0, 0, 1, 1]
            j = [2, 3, 7, 5, 5, 6]
            k = [3, 7, 4, 6, 6, 2]
            m = [6, 6, 6, 4, 0, 0]

        else:
            raise NotImplementedError(f"Mode {mode} not implemented.")

        cells_new = np.dstack(
            (
                cells[:, i],
                cells[:, j],
                cells[:, k],
                cells[:, m],
            )
        )

        cell_type_new = "tetra"

    cells_new = cells_new.reshape(-1, cells_new.shape[-1])

    return points, cells_new, cell_type_new


@mesh_or_data
def runouts(
    points,
    cells,
    cell_type,
    values=[0.1, 0.1],
    centerpoint=[0, 0, 0],
    axis=0,
    exponent=5,
    mask=slice(None),
    normalize=False,
):
    """Add simple rubber-runouts for realistic rubber-metal structures.

    Parameters
    ----------
    points : list or ndarray
        Original point coordinates.
    cells : list or ndarray
        Original point-connectivity of cells.
    cell_type : str
        A string in VTK-convention that specifies the cell type.
    values : list or ndarray, optional
        Relative amount of runouts (per coordinate) perpendicular to the axis
        (default is 10% per coordinate, i.e. [0.1, 0.1]).
    centerpoint : list or ndarray, optional
        Center-point coordinates (default is [0, 0, 0]).
    axis : int or None, optional
        Axis (default is 0).
    exponent : int, optional
        Positive exponent to control the shape of the runout. The higher
        the exponent, the steeper the transition (default is 5).
    mask : list or None, optional
        List of points to be considered (default is None).
    normalize : bool, optional
        Normalize the runouts to create indents, i.e. maintain the original shape at the
        ends (default is False).

    Returns
    -------
    points : ndarray
        Modified point coordinates.
    cells : ndarray
        Modified point-connectivity of cells.
    cell_type : str or None
        A string in VTK-convention that specifies the cell type.

    Examples
    --------
    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> rect = fem.Rectangle(a=(-3, -1), b=(3, 1), n=(31, 11))
       >>> mesh = fem.mesh.runouts(rect, axis=1, values=[0.2], normalize=True)
       >>>
       >>> mesh.plot().show()

    .. pyvista-plot::
       :force_static:

       >>> import felupe as fem
       >>>
       >>> cube = fem.Cube(a=(-3, -2, -1), b=(3, 2, 1), n=(31, 21, 11))
       >>> mesh = fem.mesh.runouts(cube, axis=2, values=[0.1, 0.3], normalize=True)
       >>>
       >>> mesh.plot().show()

    See Also
    --------
    felupe.Mesh.add_runouts : Add simple rubber-runouts for realistic rubber-metal
        structures.

    """

    dim = points.shape[1]
    runout_along = {0: [1, 2], 1: [0, 2], 2: [0, 1]}

    centerpoint = np.array(centerpoint, dtype=float)[:dim]
    values = np.array(values, dtype=float)[:dim]

    points_new = points - centerpoint
    top = points[:, axis].max()
    bottom = points[:, axis].min()

    # check symmetry
    if top == centerpoint[axis] or bottom == centerpoint[axis]:
        half_height = top - bottom
    else:
        half_height = (top - bottom) / 2

    for i, coord in enumerate(runout_along[axis][: dim - 1]):
        factor = (abs(points_new[mask, axis]) / half_height) ** exponent
        scale = 1 + factor * values[i]

        if normalize:
            scale /= np.max(np.abs(scale))

        points_new[mask, coord] *= scale

    return points_new + centerpoint, cells, cell_type
