Coverage for geometry/section.py: 65%
55 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2Geometry / Hull Section
3=======================
5Define objects for computing hull sections in colour spaces.
7This module provides functionality to compute and analyze hull sections,
8which represent the boundary surfaces of colour gamuts when intersected
9with the specified planes in various colour spaces.
11Key Components
12--------------
14- :func:`colour.geometry.hull_section`: Compute hull sections for colour
15 space analysis.
16"""
18from __future__ import annotations
20import typing
22import numpy as np
24from colour.algebra import linear_conversion
25from colour.constants import DTYPE_FLOAT_DEFAULT
27if typing.TYPE_CHECKING:
28 from colour.hints import ArrayLike, Literal, NDArrayFloat
30from colour.hints import List, cast
31from colour.utilities import as_float_array, as_float_scalar, required, validate_method
33__author__ = "Colour Developers"
34__copyright__ = "Copyright 2013 Colour Developers"
35__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
36__maintainer__ = "Colour Developers"
37__email__ = "colour-developers@colour-science.org"
38__status__ = "Production"
40__all__ = [
41 "edges_to_chord",
42 "unique_vertices",
43 "close_chord",
44 "hull_section",
45]
48def edges_to_chord(edges: ArrayLike, index: int = 0) -> NDArrayFloat:
49 """
50 Convert specified edges to a chord, starting at specified index.
52 Transforms a collection of edges into a continuous chord by
53 connecting them sequentially, beginning from the specified index
54 position.
56 Parameters
57 ----------
58 edges
59 Edges to convert to a chord.
60 index
61 Index to start forming the chord at.
63 Returns
64 -------
65 :class:`numpy.ndarray`
66 Chord.
68 Examples
69 --------
70 >>> edges = np.array(
71 ... [
72 ... [[-0.0, -0.5, 0.0], [0.5, -0.5, 0.0]],
73 ... [[-0.5, -0.5, 0.0], [-0.0, -0.5, 0.0]],
74 ... [[0.5, 0.5, 0.0], [-0.0, 0.5, 0.0]],
75 ... [[-0.0, 0.5, 0.0], [-0.5, 0.5, 0.0]],
76 ... [[-0.5, 0.0, -0.0], [-0.5, -0.5, -0.0]],
77 ... [[-0.5, 0.5, -0.0], [-0.5, 0.0, -0.0]],
78 ... [[0.5, -0.5, -0.0], [0.5, 0.0, -0.0]],
79 ... [[0.5, 0.0, -0.0], [0.5, 0.5, -0.0]],
80 ... ]
81 ... )
82 >>> edges_to_chord(edges)
83 array([[-0. , -0.5, 0. ],
84 [ 0.5, -0.5, 0. ],
85 [ 0.5, -0.5, -0. ],
86 [ 0.5, 0. , -0. ],
87 [ 0.5, 0. , -0. ],
88 [ 0.5, 0.5, -0. ],
89 [ 0.5, 0.5, 0. ],
90 [-0. , 0.5, 0. ],
91 [-0. , 0.5, 0. ],
92 [-0.5, 0.5, 0. ],
93 [-0.5, 0.5, -0. ],
94 [-0.5, 0. , -0. ],
95 [-0.5, 0. , -0. ],
96 [-0.5, -0.5, -0. ],
97 [-0.5, -0.5, 0. ],
98 [-0. , -0.5, 0. ]])
99 """
101 edge_list = cast("List[List[float]]", as_float_array(edges).tolist())
103 edges_ordered = [edge_list.pop(index)]
104 segment = np.array(edges_ordered[0][1])
106 while len(edge_list) > 0:
107 edges_array = np.array(edge_list)
108 d_0 = np.linalg.norm(edges_array[:, 0, :] - segment, axis=1)
109 d_1 = np.linalg.norm(edges_array[:, 1, :] - segment, axis=1)
110 d_0_argmin, d_1_argmin = d_0.argmin(), d_1.argmin()
112 if d_0[d_0_argmin] < d_1[d_1_argmin]:
113 edges_ordered.append(edge_list.pop(d_0_argmin))
114 segment = np.array(edges_ordered[-1][1])
115 else:
116 edges_ordered.append(edge_list.pop(d_1_argmin))
117 segment = np.array(edges_ordered[-1][0])
119 return np.reshape(as_float_array(edges_ordered), (-1, segment.shape[-1]))
122def close_chord(vertices: ArrayLike) -> NDArrayFloat:
123 """
124 Close a chord by appending its first vertex to the end.
126 Parameters
127 ----------
128 vertices
129 Vertices of the chord to close.
131 Returns
132 -------
133 :class:`numpy.ndarray`
134 Closed chord with the first vertex appended to create a closed
135 path.
137 Examples
138 --------
139 >>> close_chord(np.array([[0.0, 0.5, 0.0], [0.0, 0.0, 0.5]]))
140 array([[ 0. , 0.5, 0. ],
141 [ 0. , 0. , 0.5],
142 [ 0. , 0.5, 0. ]])
143 """
145 vertices = as_float_array(vertices)
147 return np.vstack([vertices, vertices[0]])
150def unique_vertices(
151 vertices: ArrayLike,
152 decimals: int = np.finfo(DTYPE_FLOAT_DEFAULT).precision - 1, # pyright: ignore
153) -> NDArrayFloat:
154 """
155 Return the unique vertices from the specified vertices after rounding.
157 Parameters
158 ----------
159 vertices
160 Vertices to return the unique vertices from.
161 decimals
162 Number of decimal places for rounding the vertices prior to
163 uniqueness comparison.
165 Returns
166 -------
167 :class:`numpy.ndarray`
168 Unique vertices with duplicates removed.
170 Notes
171 -----
172 - The vertices are rounded to the specified number of decimal places
173 before uniqueness comparison to handle floating-point precision
174 issues.
176 Examples
177 --------
178 >>> unique_vertices(np.array([[0.0, 0.5, 0.0], [0.0, 0.0, 0.5], [0.0, 0.5, 0.0]]))
179 array([[ 0. , 0.5, 0. ],
180 [ 0. , 0. , 0.5]])
181 """
183 vertices = as_float_array(vertices)
185 unique, indexes = np.unique(
186 vertices.round(decimals=decimals), axis=0, return_index=True
187 )
189 return unique[np.argsort(indexes)]
192@required("trimesh")
193def hull_section(
194 hull: trimesh.Trimesh, # pyright: ignore # noqa: F821
195 axis: Literal["+z", "+x", "+y"] | str = "+z",
196 origin: float = 0.5,
197 normalise: bool = False,
198) -> NDArrayFloat:
199 """
200 Compute the hull section for the specified axis at the specified origin.
202 Generate a cross-sectional contour of a 3D hull by intersecting it with
203 a plane perpendicular to the specified axis at the specified origin
204 coordinate. This operation produces vertices that define the boundary of
205 the hull's intersection with the cutting plane.
207 Parameters
208 ----------
209 hull
210 *Trimesh* hull object representing the 3D geometry to section.
211 axis
212 Axis perpendicular to which the hull section will be computed.
213 Options are "+x", "+y", or "+z".
214 origin
215 Coordinate along ``axis`` at which to compute the hull section.
216 The value represents either an absolute position or a normalised
217 position depending on the ``normalise`` parameter.
218 normalise
219 Whether to normalise the ``origin`` coordinate to the extent of the
220 hull along the specified ``axis``. When ``True``, ``origin`` is
221 interpreted as a value in [0, 1] where 0 represents the minimum
222 extent and 1 represents the maximum extent along ``axis``.
224 Returns
225 -------
226 :class:`numpy.ndarray`
227 Hull section vertices forming a closed contour. The vertices are
228 ordered to form a continuous path around the section boundary.
230 Raises
231 ------
232 ValueError
233 If no section exists on the specified axis at the specified origin,
234 typically when the cutting plane does not intersect the hull.
236 Examples
237 --------
238 >>> from colour.geometry import primitive_cube
239 >>> from colour.utilities import is_trimesh_installed
240 >>> vertices, faces, outline = primitive_cube(1, 1, 1, 2, 2, 2)
241 >>> if is_trimesh_installed:
242 ... import trimesh
243 ...
244 ... hull = trimesh.Trimesh(vertices["position"], faces, process=False)
245 ... hull_section(hull, origin=0)
246 array([[-0. , -0.5, 0. ],
247 [ 0.5, -0.5, 0. ],
248 [ 0.5, 0. , -0. ],
249 [ 0.5, 0.5, -0. ],
250 [-0. , 0.5, 0. ],
251 [-0.5, 0.5, 0. ],
252 [-0.5, 0. , -0. ],
253 [-0.5, -0.5, -0. ],
254 [-0. , -0.5, 0. ]])
255 """
257 import trimesh.intersections # noqa: PLC0415
259 axis = validate_method(
260 axis,
261 ("+z", "+x", "+y"),
262 '"{0}" axis is invalid, it must be one of {1}!',
263 )
265 if axis == "+x":
266 normal, plane = np.array([1, 0, 0]), np.array([origin, 0, 0])
267 elif axis == "+y":
268 normal, plane = np.array([0, 1, 0]), np.array([0, origin, 0])
269 elif axis == "+z":
270 normal, plane = np.array([0, 0, 1]), np.array([0, 0, origin])
272 if normalise:
273 vertices = hull.vertices * normal
274 origin = as_float_scalar(
275 linear_conversion(origin, [0, 1], [np.min(vertices), np.max(vertices)])
276 )
277 plane[plane != 0] = origin
279 section = trimesh.intersections.mesh_plane(hull, normal, plane)
280 if len(section) == 0:
281 error = f'No section exists on "{axis}" axis at {origin} origin!'
283 raise ValueError(error)
285 return close_chord(unique_vertices(edges_to_chord(section)))