Coverage for geometry/section.py: 65%

1"""

2 Geometry / Hull Section

3=======================

4

5 Define objects for computing hull sections in colour spaces.

6

7 This module provides functionality to compute and analyze hull sections,

8 which represent the boundary surfaces of colour gamuts when intersected

9 with the specified planes in various colour spaces.

10

11 Key Components

12--------------

13

14- :func:`colour.geometry.hull_section`: Compute hull sections for colour

15 space analysis.

16"""

17

18 from __future__ import annotations

19

20 import typing

21

22 import numpy as np

23

24 from colour.algebra import linear_conversion

25 from colour.constants import DTYPE_FLOAT_DEFAULT

26

27 if typing.TYPE_CHECKING:

28 from colour.hints import ArrayLike, Literal, NDArrayFloat

29

30 from colour.hints import List, cast

31 from colour.utilities import as_float_array, as_float_scalar, required, validate_method

32

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"

39

40 __all__ = [

41 "edges_to_chord",

42 "unique_vertices",

43 "close_chord",

44 "hull_section",

45]

46

47

48 def edges_to_chord(edges: ArrayLike, index: int = 0) -> NDArrayFloat:

49 """

50 Convert specified edges to a chord, starting at specified index.

51

52 Transforms a collection of edges into a continuous chord by

53 connecting them sequentially, beginning from the specified index

54 position.

55

56 Parameters

57 ----------

58 edges

59 Edges to convert to a chord.

60 index

61 Index to start forming the chord at.

62

63 Returns

64 -------

65 :class:`numpy.ndarray`

66 Chord.

67

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 """

100

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

102

103 edges_ordered = [edge_list.pop(index)]

104 segment = np.array(edges_ordered[0][1])

105

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()

111

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])

118

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

120

121

122 def close_chord(vertices: ArrayLike) -> NDArrayFloat:

123 """

124 Close a chord by appending its first vertex to the end.

125

126 Parameters

127 ----------

128 vertices

129 Vertices of the chord to close.

130

131 Returns

132 -------

133 :class:`numpy.ndarray`

134 Closed chord with the first vertex appended to create a closed

135 path.

136

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 """

144

145 vertices = as_float_array(vertices)

146

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

148

149

150 def 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.

156

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.

164

165 Returns

166 -------

167 :class:`numpy.ndarray`

168 Unique vertices with duplicates removed.

169

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.

175

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 """

182

183 vertices = as_float_array(vertices)

184

185 unique, indexes = np.unique(

186 vertices.round(decimals=decimals), axis=0, return_index=True

187 )

188

189 return unique[np.argsort(indexes)]

190

191

192@required("trimesh")

193 def 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.

201

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.

206

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``.

223

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.

229

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.

235

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 """

256

257 import trimesh.intersections # noqa: PLC0415

258

259 axis = validate_method(

260 axis,

261 ("+z", "+x", "+y"),

262 '"{0}" axis is invalid, it must be one of {1}!',

263 )

264

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])

271

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

278

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!'

282

283 raise ValueError(error)

284

285 return close_chord(unique_vertices(edges_to_chord(section)))