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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1import copy
3import numpy as np
5from .. import caching, grouping, util
6from . import color
7from .base import Visuals
8from .material import PBRMaterial, SimpleMaterial, empty_material # NOQA
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.
17 If passed UV coordinates and a single image it will
18 create a SimpleMaterial for the image.
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 """
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__)
35 # should be (n, 2) float
36 self.uv = uv
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
48 self.face_materials = face_materials
50 def _verify_hash(self):
51 """
52 Dump the cache if anything in self.vertex_attributes
53 has changed.
54 """
55 self._cache.verify()
57 @property
58 def kind(self):
59 """
60 Return the type of visual data stored
62 Returns
63 ----------
64 kind : str
65 What type of visuals are defined
66 """
67 return "texture"
69 @property
70 def defined(self):
71 """
72 Check if any data is stored
74 Returns
75 ----------
76 defined : bool
77 Are UV coordinates and images set?
78 """
79 ok = self.material is not None
80 return ok
82 def __hash__(self):
83 """
84 Get a CRC of the stored data.
86 Returns
87 --------------
88 crc : int
89 Hash of items in self.vertex_attributes
90 """
91 return self.vertex_attributes.__hash__()
93 @property
94 def uv(self):
95 """
96 Get the stored UV coordinates.
98 Returns
99 ------------
100 uv : (n, 2) float or None
101 Pixel position per-vertex.
102 """
103 return self.vertex_attributes.get("uv", None)
105 @uv.setter
106 def uv(self, values):
107 """
108 Set the UV coordinates.
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)
120 def copy(self, uv=None):
121 """
122 Return a copy of the current TextureVisuals object.
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 )
139 return copied
141 def to_color(self):
142 """
143 Convert textured visuals to a ColorVisuals with vertex
144 color calculated from texture.
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
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()
167 def update_vertices(self, mask):
168 """
169 Apply a mask to remove or duplicate vertex properties.
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)
193 def update_faces(self, mask):
194 """
195 Apply a mask to remove or duplicate face properties,
196 not applicable to texture visuals.
197 """
199 def concatenate(self, others):
200 """
201 Concatenate this TextureVisuals object with others
202 and return the result without modifying this visual.
204 Parameters
205 -----------
206 others : (n,) Visuals
207 Other visual objects to concatenate
209 Returns
210 -----------
211 concatenated : TextureVisuals
212 Concatenated visual objects
213 """
214 from .objects import concatenate
216 return concatenate(self, others)
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`).
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.
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.
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)
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))
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))
279 return result
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))
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)
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]
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))
308 # the faces are just the inverse with the new order
309 new_faces = remap[inverse].reshape((-1, faces.shape[1]))
311 # the mask for vertices and masks for other args
312 result = [new_faces]
313 result.extend(pairs.T)
315 return result
318def power_resize(image, resample=1, square=False):
319 """
320 Resize a PIL image so every dimension is a power of two.
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
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)
342 # make every dimension the largest
343 if square:
344 new_size = np.ones(2, dtype=np.int64) * new_size.max()
346 # if we're not powers of two upsize
347 if (size != new_size).any():
348 return image.resize(tuple(new_size), resample=resample)
350 return image.copy()