Coverage for geometry/section.py: 65%

55 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2Geometry / Hull Section 

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

4 

5Define objects for computing hull sections in colour spaces. 

6 

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. 

10 

11Key Components 

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

13 

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

15 space analysis. 

16""" 

17 

18from __future__ import annotations 

19 

20import typing 

21 

22import numpy as np 

23 

24from colour.algebra import linear_conversion 

25from colour.constants import DTYPE_FLOAT_DEFAULT 

26 

27if typing.TYPE_CHECKING: 

28 from colour.hints import ArrayLike, Literal, NDArrayFloat 

29 

30from colour.hints import List, cast 

31from 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 

48def 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 

122def 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 

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. 

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

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. 

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