Coverage for trimesh/visual/texture.py: 99%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import copy 

2 

3import numpy as np 

4 

5from .. import caching, grouping, util 

6from . import color 

7from .base import Visuals 

8from .material import PBRMaterial, SimpleMaterial, empty_material # NOQA 

9 

10 

11class TextureVisuals(Visuals): 

12 def __init__(self, uv=None, material=None, image=None, face_materials=None): 

13 """ 

14 Store a single material and per-vertex UV coordinates 

15 for a mesh. 

16 

17 If passed UV coordinates and a single image it will 

18 create a SimpleMaterial for the image. 

19 

20 Parameters 

21 -------------- 

22 uv : (n, 2) float 

23 UV coordinates for the mesh 

24 material : Material 

25 Store images and properties 

26 image : PIL.Image 

27 Can be passed to automatically create material 

28 """ 

29 

30 # store values we care about enough to hash 

31 self.vertex_attributes = caching.DataStore() 

32 # cache calculated values 

33 self._cache = caching.Cache(self.vertex_attributes.__hash__) 

34 

35 # should be (n, 2) float 

36 self.uv = uv 

37 

38 if material is None: 

39 if image is None: 

40 self.material = empty_material() 

41 else: 

42 # if an image is passed create a SimpleMaterial 

43 self.material = SimpleMaterial(image=image) 

44 else: 

45 # if passed assign 

46 self.material = material 

47 

48 self.face_materials = face_materials 

49 

50 def _verify_hash(self): 

51 """ 

52 Dump the cache if anything in self.vertex_attributes 

53 has changed. 

54 """ 

55 self._cache.verify() 

56 

57 @property 

58 def kind(self): 

59 """ 

60 Return the type of visual data stored 

61 

62 Returns 

63 ---------- 

64 kind : str 

65 What type of visuals are defined 

66 """ 

67 return "texture" 

68 

69 @property 

70 def defined(self): 

71 """ 

72 Check if any data is stored 

73 

74 Returns 

75 ---------- 

76 defined : bool 

77 Are UV coordinates and images set? 

78 """ 

79 ok = self.material is not None 

80 return ok 

81 

82 def __hash__(self): 

83 """ 

84 Get a CRC of the stored data. 

85 

86 Returns 

87 -------------- 

88 crc : int 

89 Hash of items in self.vertex_attributes 

90 """ 

91 return self.vertex_attributes.__hash__() 

92 

93 @property 

94 def uv(self): 

95 """ 

96 Get the stored UV coordinates. 

97 

98 Returns 

99 ------------ 

100 uv : (n, 2) float or None 

101 Pixel position per-vertex. 

102 """ 

103 return self.vertex_attributes.get("uv", None) 

104 

105 @uv.setter 

106 def uv(self, values): 

107 """ 

108 Set the UV coordinates. 

109 

110 Parameters 

111 -------------- 

112 values : (n, 2) float or None 

113 Pixel locations on a texture per- vertex 

114 """ 

115 if values is None: 

116 self.vertex_attributes.pop("uv") 

117 else: 

118 self.vertex_attributes["uv"] = np.asanyarray(values, dtype=np.float64) 

119 

120 def copy(self, uv=None): 

121 """ 

122 Return a copy of the current TextureVisuals object. 

123 

124 Returns 

125 ---------- 

126 copied : TextureVisuals 

127 Contains the same information in a new object 

128 """ 

129 if uv is None: 

130 uv = self.uv 

131 if uv is not None: 

132 uv = uv.copy() 

133 copied = TextureVisuals( 

134 uv=uv, 

135 material=self.material.copy(), 

136 face_materials=copy.copy(self.face_materials), 

137 ) 

138 

139 return copied 

140 

141 def to_color(self): 

142 """ 

143 Convert textured visuals to a ColorVisuals with vertex 

144 color calculated from texture. 

145 

146 Returns 

147 ----------- 

148 vis : trimesh.visuals.ColorVisuals 

149 Contains vertex color from texture 

150 """ 

151 # find the color at each UV coordinate 

152 colors = self.material.to_color(self.uv) 

153 # create ColorVisuals from result 

154 vis = color.ColorVisuals(vertex_colors=colors) 

155 return vis 

156 

157 def face_subset(self, face_index): 

158 """ 

159 Get a copy of 

160 """ 

161 if self.uv is not None: 

162 indices = np.unique(self.mesh.faces[face_index].flatten()) 

163 return self.copy(self.uv[indices]) 

164 else: 

165 return self.copy() 

166 

167 def update_vertices(self, mask): 

168 """ 

169 Apply a mask to remove or duplicate vertex properties. 

170 

171 Parameters 

172 ------------ 

173 mask : (len(vertices),) bool or (n,) int 

174 Mask which can be used like: `vertex_attribute[mask]` 

175 """ 

176 # collect updated masked values 

177 updates = {} 

178 for key, value in self.vertex_attributes.items(): 

179 # DataStore will convert None to zero-length array 

180 if len(value) == 0: 

181 continue 

182 try: 

183 # store the update 

184 updates[key] = value[mask] 

185 except BaseException: 

186 # usual reason is an incorrect size or index 

187 util.log.warning(f"failed to update visual: `{key}`") 

188 # clear all values from the vertex attributes 

189 self.vertex_attributes.clear() 

190 # apply the updated values 

191 self.vertex_attributes.update(updates) 

192 

193 def update_faces(self, mask): 

194 """ 

195 Apply a mask to remove or duplicate face properties, 

196 not applicable to texture visuals. 

197 """ 

198 

199 def concatenate(self, others): 

200 """ 

201 Concatenate this TextureVisuals object with others 

202 and return the result without modifying this visual. 

203 

204 Parameters 

205 ----------- 

206 others : (n,) Visuals 

207 Other visual objects to concatenate 

208 

209 Returns 

210 ----------- 

211 concatenated : TextureVisuals 

212 Concatenated visual objects 

213 """ 

214 from .objects import concatenate 

215 

216 return concatenate(self, others) 

217 

218 

219def unmerge_faces(faces, *args, **kwargs): 

220 """ 

221 Textured meshes can come with faces referencing vertex 

222 indices (`v`) and an array the same shape which references 

223 vertex texture indices (`vt`) and sometimes even normal (`vn`). 

224 

225 Vertex locations with different values of any of these can't 

226 be considered the "same" vertex, and for our simple data 

227 model we need to not combine these vertices. 

228 

229 Parameters 

230 ------------- 

231 faces : (n, d) int 

232 References vertex indices 

233 *args : (n, d) int 

234 Various references of corresponding values 

235 This is usually UV coordinates or normal indexes 

236 maintain_faces : bool 

237 Do not alter original faces and return no-op masks. 

238 

239 Returns 

240 ------------- 

241 new_faces : (m, d) int 

242 New faces for masked vertices 

243 mask_v : (p,) int 

244 A mask to apply to vertices 

245 mask_* : (p,) int 

246 A mask to apply to vt array to get matching UV coordinates 

247 Returns as many of these as args were passed 

248 """ 

249 # unfortunately Python2 doesn't let us put named kwargs 

250 # after an `*args` sequence so we have to do this ugly get 

251 maintain_faces = kwargs.get("maintain_faces", False) 

252 

253 # don't alter faces 

254 if maintain_faces: 

255 # start with not altering faces at all 

256 result = [faces] 

257 # find the maximum index referenced by faces 

258 max_idx = faces.max() 

259 # add a vertex mask which is just ordered 

260 result.append(np.arange(max_idx + 1)) 

261 

262 # now given the order is fixed do our best on the rest of the order 

263 for arg in args: 

264 # create a mask of the attribute-vertex mapping 

265 # note that these might conflict since we're not unmerging 

266 masks = np.full((3, max_idx + 1), -1, dtype=np.int64) 

267 # set the mask using the unmodified face indexes 

268 for i, f, a in zip(range(3), faces.T, arg.T): 

269 masks[i][f] = a 

270 # find the most commonly occurring attribute (i.e. UV coordinate) 

271 # and use that index note that this is doing a float conversion 

272 # and then median before converting back to int: could also do this as 

273 # a column diff and sort but this seemed easier and is fast enough 

274 # turn default attribute value of -1 to nan before median computation 

275 # and use nanmedian to compute the median ignoring the nan values 

276 masks_nan = np.where(masks != -1, masks, np.nan) 

277 result.append(np.nanmedian(masks_nan, axis=0).astype(np.int64)) 

278 

279 return result 

280 

281 # stack into pairs of (vertex index, texture index) 

282 stackable = [np.asanyarray(faces).reshape(-1)] 

283 # append multiple args to the correlated stack 

284 # this is usually UV coordinates (vt) and normals (vn) 

285 for arg in args: 

286 stackable.append(np.asanyarray(arg).reshape(-1)) 

287 

288 # unify them into rows of a numpy array 

289 stack = np.column_stack(stackable) 

290 # find unique pairs: we're trying to avoid merging 

291 # vertices that have the same position but different 

292 # texture coordinates 

293 unique, inverse = grouping.unique_rows(stack) 

294 

295 # only take the unique pairs 

296 pairs = stack[unique] 

297 # try to maintain original vertex order 

298 order = pairs[:, 0].argsort() 

299 # apply the order to the pairs 

300 pairs = pairs[order] 

301 

302 # we re-ordered the vertices to try to maintain 

303 # the original vertex order as much as possible 

304 # so to reconstruct the faces we need to remap 

305 remap = np.zeros(len(order), dtype=np.int64) 

306 remap[order] = np.arange(len(order)) 

307 

308 # the faces are just the inverse with the new order 

309 new_faces = remap[inverse].reshape((-1, faces.shape[1])) 

310 

311 # the mask for vertices and masks for other args 

312 result = [new_faces] 

313 result.extend(pairs.T) 

314 

315 return result 

316 

317 

318def power_resize(image, resample=1, square=False): 

319 """ 

320 Resize a PIL image so every dimension is a power of two. 

321 

322 Parameters 

323 ------------ 

324 image : PIL.Image 

325 Input image 

326 resample : int 

327 Passed to Image.resize 

328 square : bool 

329 If True, upsize to a square image 

330 

331 Returns 

332 ------------- 

333 resized : PIL.Image 

334 Input image resized 

335 """ 

336 # what is the current resolution of the image in pixels 

337 size = np.array(image.size, dtype=np.int64) 

338 # what is the resolution of the image upsized to the nearest 

339 # power of two on each axis: allow rectangular textures 

340 new_size = (2 ** np.ceil(np.log2(size))).astype(np.int64) 

341 

342 # make every dimension the largest 

343 if square: 

344 new_size = np.ones(2, dtype=np.int64) * new_size.max() 

345 

346 # if we're not powers of two upsize 

347 if (size != new_size).any(): 

348 return image.resize(tuple(new_size), resample=resample) 

349 

350 return image.copy()