Source code for colour.geometry.section

"""
Geometry / Hull Section
=======================

Define objects for computing hull sections in colour spaces.

This module provides functionality to compute and analyze hull sections,
which represent the boundary surfaces of colour gamuts when intersected
with the specified planes in various colour spaces.

Key Components
--------------

-   :func:`colour.geometry.hull_section`: Compute hull sections for colour
    space analysis.
"""

from __future__ import annotations

import typing

import numpy as np

from colour.algebra import linear_conversion

if typing.TYPE_CHECKING:
    from colour.hints import ArrayLike, Literal, NDArrayFloat

from colour.hints import List, cast
from colour.utilities import as_float_array, as_float_scalar, required, validate_method

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "edges_to_chord",
    "unique_vertices",
    "close_chord",
    "hull_section",
]


def edges_to_chord(edges: ArrayLike, index: int = 0) -> NDArrayFloat:
    """
    Convert specified edges to a chord, starting at specified index.

    Transforms a collection of edges into a continuous chord by
    connecting them sequentially, beginning from the specified index
    position.

    Parameters
    ----------
    edges
        Edges to convert to a chord.
    index
        Index to start forming the chord at.

    Returns
    -------
    :class:`numpy.ndarray`
        Chord.

    Examples
    --------
    >>> edges = np.array(
    ...     [
    ...         [[-0.0, -0.5, 0.0], [0.5, -0.5, 0.0]],
    ...         [[-0.5, -0.5, 0.0], [-0.0, -0.5, 0.0]],
    ...         [[0.5, 0.5, 0.0], [-0.0, 0.5, 0.0]],
    ...         [[-0.0, 0.5, 0.0], [-0.5, 0.5, 0.0]],
    ...         [[-0.5, 0.0, -0.0], [-0.5, -0.5, -0.0]],
    ...         [[-0.5, 0.5, -0.0], [-0.5, 0.0, -0.0]],
    ...         [[0.5, -0.5, -0.0], [0.5, 0.0, -0.0]],
    ...         [[0.5, 0.0, -0.0], [0.5, 0.5, -0.0]],
    ...     ]
    ... )
    >>> edges_to_chord(edges)
    array([[-0. , -0.5,  0. ],
           [ 0.5, -0.5,  0. ],
           [ 0.5, -0.5, -0. ],
           [ 0.5,  0. , -0. ],
           [ 0.5,  0. , -0. ],
           [ 0.5,  0.5, -0. ],
           [ 0.5,  0.5,  0. ],
           [-0. ,  0.5,  0. ],
           [-0. ,  0.5,  0. ],
           [-0.5,  0.5,  0. ],
           [-0.5,  0.5, -0. ],
           [-0.5,  0. , -0. ],
           [-0.5,  0. , -0. ],
           [-0.5, -0.5, -0. ],
           [-0.5, -0.5,  0. ],
           [-0. , -0.5,  0. ]])
    """

    edge_list = cast("List[List[float]]", as_float_array(edges).tolist())

    edges_ordered = [edge_list.pop(index)]
    segment = np.array(edges_ordered[0][1])

    while len(edge_list) > 0:
        edges_array = np.array(edge_list)
        d_0 = np.linalg.norm(edges_array[:, 0, :] - segment, axis=1)
        d_1 = np.linalg.norm(edges_array[:, 1, :] - segment, axis=1)
        d_0_argmin, d_1_argmin = d_0.argmin(), d_1.argmin()

        if d_0[d_0_argmin] < d_1[d_1_argmin]:
            edges_ordered.append(edge_list.pop(d_0_argmin))
            segment = np.array(edges_ordered[-1][1])
        else:
            edges_ordered.append(edge_list.pop(d_1_argmin))
            segment = np.array(edges_ordered[-1][0])

    return np.reshape(as_float_array(edges_ordered), (-1, segment.shape[-1]))


def close_chord(vertices: ArrayLike) -> NDArrayFloat:
    """
    Close a chord by appending its first vertex to the end.

    Parameters
    ----------
    vertices
        Vertices of the chord to close.

    Returns
    -------
    :class:`numpy.ndarray`
        Closed chord with the first vertex appended to create a closed
        path.

    Examples
    --------
    >>> close_chord(np.array([[0.0, 0.5, 0.0], [0.0, 0.0, 0.5]]))
    array([[0. , 0.5, 0. ],
           [0. , 0. , 0.5],
           [0. , 0.5, 0. ]])
    """

    vertices = as_float_array(vertices)

    return np.vstack([vertices, vertices[0]])


def unique_vertices(
    vertices: ArrayLike,
    decimals: int = int(np.finfo(np.float64).precision - 1),
) -> NDArrayFloat:
    """
    Return the unique vertices from the specified vertices after rounding.

    Parameters
    ----------
    vertices
        Vertices to return the unique vertices from.
    decimals
        Number of decimal places for rounding the vertices prior to
        uniqueness comparison.

    Returns
    -------
    :class:`numpy.ndarray`
        Unique vertices with duplicates removed.

    Notes
    -----
    -   The vertices are rounded to the specified number of decimal places
        before uniqueness comparison to handle floating-point precision
        issues.

    Examples
    --------
    >>> unique_vertices(np.array([[0.0, 0.5, 0.0], [0.0, 0.0, 0.5], [0.0, 0.5, 0.0]]))
    array([[0. , 0.5, 0. ],
           [0. , 0. , 0.5]])
    """

    vertices = as_float_array(vertices)

    unique, indexes = np.unique(
        vertices.round(decimals=decimals), axis=0, return_index=True
    )

    return unique[np.argsort(indexes)]


[docs] @required("trimesh") def hull_section( hull: trimesh.Trimesh, # pyright: ignore # noqa: F821 axis: Literal["+z", "+x", "+y"] | str = "+z", origin: float = 0.5, normalise: bool = False, ) -> NDArrayFloat: """ Compute the hull section for the specified axis at the specified origin. Generate a cross-sectional contour of a 3D hull by intersecting it with a plane perpendicular to the specified axis at the specified origin coordinate. This operation produces vertices that define the boundary of the hull's intersection with the cutting plane. Parameters ---------- hull *Trimesh* hull object representing the 3D geometry to section. axis Axis perpendicular to which the hull section will be computed. Options are "+x", "+y", or "+z". origin Coordinate along ``axis`` at which to compute the hull section. The value represents either an absolute position or a normalised position depending on the ``normalise`` parameter. normalise Whether to normalise the ``origin`` coordinate to the extent of the hull along the specified ``axis``. When ``True``, ``origin`` is interpreted as a value in [0, 1] where 0 represents the minimum extent and 1 represents the maximum extent along ``axis``. Returns ------- :class:`numpy.ndarray` Hull section vertices forming a closed contour. The vertices are ordered to form a continuous path around the section boundary. Raises ------ ValueError If no section exists on the specified axis at the specified origin, typically when the cutting plane does not intersect the hull. Examples -------- >>> from colour.geometry import primitive_cube >>> from colour.utilities import is_trimesh_installed >>> vertices, faces, outline = primitive_cube(1, 1, 1, 2, 2, 2) >>> if is_trimesh_installed: ... import trimesh ... ... hull = trimesh.Trimesh(vertices["position"], faces, process=False) ... hull_section(hull, origin=0) array([[-0. , -0.5, 0. ], [ 0.5, -0.5, 0. ], [ 0.5, 0. , -0. ], [ 0.5, 0.5, -0. ], [-0. , 0.5, 0. ], [-0.5, 0.5, 0. ], [-0.5, 0. , -0. ], [-0.5, -0.5, -0. ], [-0. , -0.5, 0. ]]) """ import trimesh.intersections # noqa: PLC0415 axis = validate_method( axis, ("+z", "+x", "+y"), '"{0}" axis is invalid, it must be one of {1}!', ) if axis == "+x": normal, plane = np.array([1, 0, 0]), np.array([origin, 0, 0]) elif axis == "+y": normal, plane = np.array([0, 1, 0]), np.array([0, origin, 0]) elif axis == "+z": normal, plane = np.array([0, 0, 1]), np.array([0, 0, origin]) if normalise: vertices = hull.vertices * normal origin = as_float_scalar( linear_conversion(origin, [0, 1], [np.min(vertices), np.max(vertices)]) ) plane = np.where(plane != 0, origin, plane) section = trimesh.intersections.mesh_plane(hull, normal, plane) if len(section) == 0: error = f'No section exists on "{axis}" axis at {origin} origin!' raise ValueError(error) return close_chord(unique_vertices(edges_to_chord(section)))