Coverage for trimesh/visual/material.py: 91%
444 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
1"""
2material.py
3-------------
5Store visual materials as objects.
6"""
8import abc
9import copy
11import numpy as np
13from .. import exceptions, util
14from ..constants import tol
15from ..typed import NDArray
16from . import color
18try:
19 from PIL import Image
20except BaseException as E:
21 Image = exceptions.ExceptionWrapper(E)
23# epsilon for comparing floating point
24_eps = 1e-5
27class Material(util.ABC):
28 def __init__(self, *args, **kwargs):
29 raise NotImplementedError("must be subclassed!")
31 @abc.abstractmethod
32 def __hash__(self):
33 raise NotImplementedError("must be subclassed!")
35 @property
36 @abc.abstractmethod
37 def main_color(self):
38 """
39 The "average" color of this material.
41 Returns
42 ---------
43 color : (4,) uint8
44 Average color of this material.
45 """
47 @property
48 def name(self):
49 if hasattr(self, "_name"):
50 return self._name
51 return "material_0"
53 @name.setter
54 def name(self, value):
55 if value is not None:
56 self._name = value
58 def copy(self):
59 return copy.deepcopy(self)
62class SimpleMaterial(Material):
63 """
64 Hold a single image texture.
65 """
67 def __init__(
68 self,
69 image=None,
70 diffuse=None,
71 ambient=None,
72 specular=None,
73 glossiness=None,
74 name=None,
75 **kwargs,
76 ):
77 # save image
78 self.image = image
79 self.name = name
80 # save material colors as RGBA
81 self.ambient = color.to_rgba(ambient)
82 self.diffuse = color.to_rgba(diffuse)
83 self.specular = color.to_rgba(specular)
85 # save Ns
86 self.glossiness = glossiness
88 # save other keyword arguments
89 self.kwargs = kwargs
91 def to_color(self, uv):
92 return color.uv_to_color(uv, self.image)
94 def to_obj(self, name=None):
95 """
96 Convert the current material to an OBJ format
97 material.
99 Parameters
100 -----------
101 name : str or None
102 Name to apply to the material
104 Returns
105 -----------
106 tex_name : str
107 Name of material
108 mtl_name : str
109 Name of mtl file in files
110 files : dict
111 Data as {file name : bytes}
112 """
113 # material parameters as 0.0-1.0 RGB
114 Ka = color.to_float(self.ambient)[:3]
115 Kd = color.to_float(self.diffuse)[:3]
116 Ks = color.to_float(self.specular)[:3]
118 if name is None:
119 name = self.name
121 # create an MTL file
122 mtl = [
123 f"newmtl {name}",
124 "Ka {:0.8f} {:0.8f} {:0.8f}".format(*Ka),
125 "Kd {:0.8f} {:0.8f} {:0.8f}".format(*Kd),
126 "Ks {:0.8f} {:0.8f} {:0.8f}".format(*Ks),
127 f"Ns {self.glossiness:0.8f}",
128 ]
130 # collect the OBJ data into files
131 data = {}
133 if self.image is not None:
134 image_type = self.image.format
135 # what is the name of the export image to save
136 if image_type is None:
137 image_type = "png"
138 image_name = f"{name}.{image_type.lower()}"
139 # save the reference to the image
140 mtl.append(f"map_Kd {image_name}")
142 # save the image texture as bytes in the original format
143 f_obj = util.BytesIO()
144 self.image.save(fp=f_obj, format=image_type)
145 f_obj.seek(0)
146 data[image_name] = f_obj.read()
148 data[f"{name}.mtl"] = "\n".join(mtl).encode("utf-8")
150 return data, name
152 def __hash__(self):
153 """
154 Provide a hash of the material so we can detect
155 duplicates.
157 Returns
158 ------------
159 hash : int
160 Hash of image and parameters
161 """
162 if hasattr(self.image, "tobytes"):
163 # start with hash of raw image bytes
164 hashed = hash(self.image.tobytes())
165 else:
166 # otherwise start with zero
167 hashed = 0
168 # we will add additional parameters with
169 # an in-place xor of the additional value
170 # if stored as numpy arrays add parameters
171 if hasattr(self.ambient, "tobytes"):
172 hashed ^= hash(self.ambient.tobytes())
173 if hasattr(self.diffuse, "tobytes"):
174 hashed ^= hash(self.diffuse.tobytes())
175 if hasattr(self.specular, "tobytes"):
176 hashed ^= hash(self.specular.tobytes())
177 if isinstance(self.glossiness, float):
178 hashed ^= hash(int(self.glossiness * 1000))
179 return hashed
181 @property
182 def main_color(self):
183 """
184 Return the most prominent color.
185 """
186 return self.diffuse
188 @property
189 def glossiness(self):
190 if hasattr(self, "_glossiness"):
191 return self._glossiness
192 return 1.0
194 @glossiness.setter
195 def glossiness(self, value):
196 if value is None:
197 return
198 self._glossiness = float(value)
200 def to_pbr(self):
201 """
202 Convert the current simple material to a
203 PBR material.
205 Returns
206 ------------
207 pbr : PBRMaterial
208 Contains material information in PBR format.
209 """
210 # convert specular exponent to roughness
211 roughness = (2 / (self.glossiness + 2)) ** (1.0 / 4.0)
213 return PBRMaterial(
214 roughnessFactor=roughness,
215 baseColorTexture=self.image,
216 baseColorFactor=self.diffuse,
217 )
220class MultiMaterial(Material):
221 def __init__(self, materials=None, **kwargs):
222 """
223 Wrapper for a list of Materials.
225 Parameters
226 ----------
227 materials : list[Material] | None
228 List of materials with which the container to be initialized.
229 """
230 if materials is None:
231 self.materials = []
232 else:
233 self.materials = materials
235 def to_pbr(self):
236 """
237 TODO : IMPLEMENT
238 """
239 pbr = [m for m in self.materials if isinstance(m, PBRMaterial)]
240 if len(pbr) == 0:
241 return PBRMaterial()
242 return pbr[0]
244 def __hash__(self):
245 """
246 Provide a hash of the multi material so we can detect
247 duplicates.
249 Returns
250 ------------
251 hash : int
252 Xor hash of the contained materials.
253 """
254 return int(np.bitwise_xor.reduce([hash(m) for m in self.materials]))
256 def __iter__(self):
257 return iter(self.materials)
259 def __next__(self):
260 return next(self.materials)
262 def __len__(self):
263 return len(self.materials)
265 @property
266 def main_color(self):
267 """
268 The "average" color of this material.
270 Returns
271 ---------
272 color : (4,) uint8
273 Average color of this material.
274 """
276 def add(self, material):
277 """
278 Adds new material to the container.
280 Parameters
281 ----------
282 material : Material
283 The material to be added.
284 """
285 self.materials.append(material)
287 def get(self, idx):
288 """
289 Get material by index.
291 Parameters
292 ----------
293 idx : int
294 Index of the material to be retrieved.
296 Returns
297 -------
298 The material on the given index.
299 """
300 return self.materials[idx]
303class PBRMaterial(Material):
304 """
305 Create a material for physically based rendering as
306 specified by GLTF 2.0:
307 https://git.io/fhkPZ
309 Parameters with `Texture` in them must be PIL.Image objects
310 """
312 def __init__(
313 self,
314 name=None,
315 emissiveFactor=None,
316 emissiveTexture=None,
317 baseColorFactor=None,
318 metallicFactor=None,
319 roughnessFactor=None,
320 normalTexture=None,
321 occlusionTexture=None,
322 baseColorTexture=None,
323 metallicRoughnessTexture=None,
324 doubleSided=False,
325 alphaMode=None,
326 alphaCutoff=None,
327 **kwargs,
328 ):
329 # store values in an internal dict
330 self._data = {}
332 # (3,) float
333 self.emissiveFactor = emissiveFactor
334 # (3,) or (4,) float with RGBA colors
335 self.baseColorFactor = baseColorFactor
337 # float
338 self.metallicFactor = metallicFactor
339 self.roughnessFactor = roughnessFactor
340 self.alphaCutoff = alphaCutoff
342 # PIL image
343 self.normalTexture = normalTexture
344 self.emissiveTexture = emissiveTexture
345 self.occlusionTexture = occlusionTexture
346 self.baseColorTexture = baseColorTexture
347 self.metallicRoughnessTexture = metallicRoughnessTexture
349 # bool
350 self.doubleSided = doubleSided
352 # str
353 self.name = name
354 self.alphaMode = alphaMode
356 if len(kwargs) > 0:
357 util.log.debug(
358 "unsupported material keys: {}".format(", ".join(kwargs.keys()))
359 )
361 @property
362 def emissiveFactor(self):
363 """
364 The factors for the emissive color of the material.
365 This value defines linear multipliers for the sampled
366 texels of the emissive texture.
368 Returns
369 -----------
370 emissiveFactor : (3,) float
371 Ech element in the array MUST be greater than
372 or equal to 0 and less than or equal to 1.
373 """
374 return self._data.get("emissiveFactor")
376 @emissiveFactor.setter
377 def emissiveFactor(self, value):
378 if value is None:
379 # passing none effectively removes value
380 self._data.pop("emissiveFactor", None)
381 else:
382 # non-None values must be a floating point
383 emissive = np.array(value, dtype=np.float64).reshape(3)
384 if emissive.min() < -_eps:
385 raise ValueError("all factors must be greater than 0.0")
386 self._data["emissiveFactor"] = emissive
388 @property
389 def alphaMode(self):
390 """
391 The material alpha rendering mode enumeration
392 specifying the interpretation of the alpha value of
393 the base color.
395 Returns
396 -----------
397 alphaMode : str
398 One of 'OPAQUE', 'MASK', 'BLEND'
399 """
400 return self._data.get("alphaMode")
402 @alphaMode.setter
403 def alphaMode(self, value):
404 if value is None:
405 # passing none effectively removes value
406 self._data.pop("alphaMode", None)
407 else:
408 # non-None values must be one of three values
409 value = str(value).upper().strip()
410 if value not in ["OPAQUE", "MASK", "BLEND"]:
411 raise ValueError("incorrect alphaMode: %s", value)
412 self._data["alphaMode"] = value
414 @property
415 def alphaCutoff(self):
416 """
417 Specifies the cutoff threshold when in MASK alpha mode.
418 If the alpha value is greater than or equal to this value
419 then it is rendered as fully opaque, otherwise, it is rendered
420 as fully transparent. A value greater than 1.0 will render
421 the entire material as fully transparent. This value MUST be
422 ignored for other alpha modes. When alphaMode is not defined,
423 this value MUST NOT be defined.
425 Returns
426 -----------
427 alphaCutoff : float
428 Value of cutoff.
429 """
430 return self._data.get("alphaCutoff")
432 @alphaCutoff.setter
433 def alphaCutoff(self, value):
434 if value is None:
435 # passing none effectively removes value
436 self._data.pop("alphaCutoff", None)
437 else:
438 self._data["alphaCutoff"] = float(value)
440 @property
441 def doubleSided(self):
442 """
443 Specifies whether the material is double sided.
445 Returns
446 -----------
447 doubleSided : bool
448 Specifies whether the material is double sided.
449 """
450 return self._data.get("doubleSided")
452 @doubleSided.setter
453 def doubleSided(self, value):
454 if value is None:
455 # passing none effectively removes value
456 self._data.pop("doubleSided", None)
457 else:
458 self._data["doubleSided"] = bool(value)
460 @property
461 def metallicFactor(self):
462 """
463 The factor for the metalness of the material. This value
464 defines a linear multiplier for the sampled metalness values
465 of the metallic-roughness texture.
468 Returns
469 -----------
470 metallicFactor : float
471 How metally is the material
472 """
473 return self._data.get("metallicFactor")
475 @metallicFactor.setter
476 def metallicFactor(self, value):
477 if value is None:
478 # passing none effectively removes value
479 self._data.pop("metallicFactor", None)
480 else:
481 self._data["metallicFactor"] = float(value)
483 @property
484 def roughnessFactor(self):
485 """
486 The factor for the roughness of the material. This value
487 defines a linear multiplier for the sampled roughness values
488 of the metallic-roughness texture.
490 Returns
491 -----------
492 roughnessFactor : float
493 Roughness of material.
494 """
495 return self._data.get("roughnessFactor")
497 @roughnessFactor.setter
498 def roughnessFactor(self, value):
499 if value is None:
500 # passing none effectively removes value
501 self._data.pop("roughnessFactor", None)
502 else:
503 self._data["roughnessFactor"] = float(value)
505 @property
506 def baseColorFactor(self):
507 """
508 The factors for the base color of the material. This
509 value defines linear multipliers for the sampled texels
510 of the base color texture.
512 Returns
513 ---------
514 color : (4,) uint8
515 RGBA color
516 """
517 return self._data.get("baseColorFactor")
519 @baseColorFactor.setter
520 def baseColorFactor(self, value):
521 if value is None:
522 # passing none effectively removes value
523 self._data.pop("baseColorFactor", None)
524 else:
525 # non-None values must be RGBA color
526 self._data["baseColorFactor"] = color.to_rgba(value)
528 @property
529 def normalTexture(self):
530 """
531 The normal map texture.
533 Returns
534 ----------
535 image : PIL.Image
536 Normal texture.
537 """
538 return self._data.get("normalTexture")
540 @normalTexture.setter
541 def normalTexture(self, value):
542 if value is None:
543 # passing none effectively removes value
544 self._data.pop("normalTexture", None)
545 else:
546 self._data["normalTexture"] = value
548 @property
549 def emissiveTexture(self):
550 """
551 The emissive texture.
553 Returns
554 ----------
555 image : PIL.Image
556 Emissive texture.
557 """
558 return self._data.get("emissiveTexture")
560 @emissiveTexture.setter
561 def emissiveTexture(self, value):
562 if value is None:
563 # passing none effectively removes value
564 self._data.pop("emissiveTexture", None)
565 else:
566 self._data["emissiveTexture"] = value
568 @property
569 def occlusionTexture(self):
570 """
571 The occlusion texture.
573 Returns
574 ----------
575 image : PIL.Image
576 Occlusion texture.
577 """
578 return self._data.get("occlusionTexture")
580 @occlusionTexture.setter
581 def occlusionTexture(self, value):
582 if value is None:
583 # passing none effectively removes value
584 self._data.pop("occlusionTexture", None)
585 else:
586 self._data["occlusionTexture"] = value
588 @property
589 def baseColorTexture(self):
590 """
591 The base color texture image.
593 Returns
594 ----------
595 image : PIL.Image
596 Color texture.
597 """
598 return self._data.get("baseColorTexture")
600 @baseColorTexture.setter
601 def baseColorTexture(self, value):
602 if value is None:
603 # passing none effectively removes value
604 self._data.pop("baseColorTexture", None)
605 else:
606 # non-None values must be RGBA color
607 self._data["baseColorTexture"] = value
609 @property
610 def metallicRoughnessTexture(self):
611 """
612 The metallic-roughness texture.
614 Returns
615 ----------
616 image : PIL.Image
617 Metallic-roughness texture.
618 """
619 return self._data.get("metallicRoughnessTexture")
621 @metallicRoughnessTexture.setter
622 def metallicRoughnessTexture(self, value):
623 if value is None:
624 # passing none effectively removes value
625 self._data.pop("metallicRoughnessTexture", None)
626 else:
627 self._data["metallicRoughnessTexture"] = value
629 @property
630 def name(self):
631 return self._data.get("name")
633 @name.setter
634 def name(self, value):
635 if value is None:
636 # passing none effectively removes value
637 self._data.pop("name", None)
638 else:
639 self._data["name"] = value
641 def copy(self):
642 # doing a straight deepcopy fails due to PIL images
643 kwargs = {}
644 # collect stored values as kwargs
645 for k, v in self._data.items():
646 if v is None:
647 continue
648 if hasattr(v, "copy"):
649 # use an objects explicit copy if available
650 kwargs[k] = v.copy()
651 else:
652 # otherwise just hope deepcopy does something
653 kwargs[k] = copy.deepcopy(v)
654 return PBRMaterial(**kwargs)
656 def to_color(self, uv):
657 """
658 Get the rough color at a list of specified UV
659 coordinates.
661 Parameters
662 -------------
663 uv : (n, 2) float
664 UV coordinates on the material
666 Returns
667 -------------
668 colors
669 """
670 colors = color.uv_to_color(uv=uv, image=self.baseColorTexture)
671 if colors is None and self.baseColorFactor is not None:
672 colors = self.baseColorFactor.copy()
673 return colors
675 def to_simple(self):
676 """
677 Get a copy of the current PBR material as
678 a simple material.
680 Returns
681 ------------
682 simple : SimpleMaterial
683 Contains material information in a simple manner
684 """
685 # `self.baseColorFactor` is really a linear value
686 # so the "right" thing to do here would probably be:
687 # `diffuse = color.to_rgba(color.linear_to_srgb(self.baseColorFactor))`
688 # however that subtle transformation seems like it would confuse
689 # the absolute heck out of people looking at this. If someone wants
690 # this and has opinions happy to accept that change but otherwise
691 # we'll just keep passing it through as "probably-RGBA-like"
692 return SimpleMaterial(
693 image=self.baseColorTexture,
694 diffuse=self.baseColorFactor,
695 name=self.name,
696 )
698 @property
699 def main_color(self):
700 # will return default color if None
701 result = color.to_rgba(self.baseColorFactor)
702 return result
704 def __hash__(self):
705 """
706 Provide a hash of the material so we can detect
707 duplicate materials.
709 Returns
710 ------------
711 hash : int
712 Hash of image and parameters
713 """
714 return hash(
715 b"".join(
716 np.asanyarray(v).tobytes() for v in self._data.values() if v is not None
717 )
718 )
721def empty_material(color: NDArray[np.uint8] | None = None) -> SimpleMaterial:
722 """
723 Return an empty material set to a single color
725 Parameters
726 -----------
727 color : None or (3,) uint8
728 RGB color
730 Returns
731 -------------
732 material : SimpleMaterial
733 Image is a a four pixel RGB
734 """
736 # create a one pixel RGB image
737 return SimpleMaterial(image=color_image(color=color))
740def color_image(color: NDArray[np.uint8] | None = None):
741 """
742 Generate an image with one color.
744 Parameters
745 ----------
746 color
747 Optional uint8 color
749 Returns
750 ----------
751 image
752 A (2, 2) RGBA image with the specified color.
753 """
754 # only raise an error further down the line
755 if isinstance(Image, exceptions.ExceptionWrapper):
756 return Image
757 # start with a single default RGBA color
758 single = np.array([100, 100, 100, 255], dtype=np.uint8)
759 if np.shape(color) in ((3,), (4,)):
760 single[: len(color)] = color
761 # tile into a (2, 2) image and return
762 return Image.fromarray(np.tile(single, 4).reshape((2, 2, 4)).astype(np.uint8))
765def pack(
766 materials,
767 uvs,
768 deduplicate=True,
769 padding: int = 2,
770 max_tex_size_individual=8192,
771 max_tex_size_fused=8192,
772):
773 """
774 Pack multiple materials with texture into a single material.
776 UV coordinates outside of the 0.0-1.0 range will be coerced
777 into this range using a "wrap" behavior (i.e. modulus).
779 Alpha blending and backface culling settings are not supported!
780 Returns a material with alpha values set, but alpha blending disabled.
782 Parameters
783 -----------
784 materials : (n,) Material
785 List of multiple materials
786 uvs : (n, m, 2) float
787 Original UV coordinates
788 padding : int
789 Number of pixels to pad each image with.
790 max_tex_size_individual : int
791 Maximum size of each individual texture.
792 max_tex_size_fused : int | None
793 Maximum size of the combined texture.
794 Individual texture size will be reduced to fit.
795 Set to None to allow infinite size.
797 Returns
798 ------------
799 material : SimpleMaterial
800 Combined material.
801 uv : (p, 2) float
802 Combined UV coordinates in the 0.0-1.0 range.
803 """
805 import collections
807 from PIL import Image
809 from ..path import packing
811 def multiply_factor(img, factor, mode):
812 """
813 Multiply an image by a factor.
814 """
815 if factor is None:
816 return img.convert(mode)
817 img = (
818 (np.array(img.convert(mode), dtype=np.float64) * factor)
819 .round()
820 .astype(np.uint8)
821 )
822 return Image.fromarray(img)
824 def get_base_color_texture(mat):
825 """
826 Logic for extracting a simple image from each material.
827 """
828 # extract an image for each material
829 img = None
830 if isinstance(mat, PBRMaterial):
831 if mat.baseColorTexture is not None:
832 img = multiply_factor(
833 mat.baseColorTexture, factor=mat.baseColorFactor, mode="RGBA"
834 )
835 elif mat.baseColorFactor is not None:
836 # Per glTF 2.0 spec (https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html):
837 # - baseColorFactor: "defines linear multipliers for the sampled texels"
838 # - baseColorTexture: "RGB components MUST be encoded with the sRGB transfer function"
839 #
840 # Therefore when creating a texture from baseColorFactor values,
841 # we need to convert from linear to sRGB space
842 c_linear = color.to_float(mat.baseColorFactor).reshape(4)
844 # Apply proper sRGB gamma correction to RGB channels
845 c_srgb = np.concatenate(
846 [color.linear_to_srgb(c_linear[:3]), c_linear[3:4]]
847 )
849 # Convert to uint8
850 c = np.round(c_srgb * 255).astype(np.uint8)
851 assert c.shape == (4,)
852 img = color_image(c)
854 if img is not None and mat.alphaMode != "BLEND":
855 # we can't handle alpha blending well, but we can bake alpha cutoff
856 mode = img.mode
857 img = np.array(img)
858 if mat.alphaMode == "MASK":
859 img[..., 3] = np.where(img[..., 3] > mat.alphaCutoff * 255, 255, 0)
860 elif mat.alphaMode == "OPAQUE" or mat.alphaMode is None:
861 if "A" in mode:
862 img[..., 3] = 255
863 img = Image.fromarray(img)
864 elif getattr(mat, "image", None) is not None:
865 img = mat.image
866 elif np.shape(getattr(mat, "diffuse", [])) == (4,):
867 # return a one pixel image
868 img = color_image(mat.diffuse)
870 if img is None:
871 # return a one pixel image
872 img = color_image()
873 # make sure we're always returning in RGBA mode
874 return img.convert("RGBA")
876 def get_metallic_roughness_texture(mat):
877 """
878 Logic for extracting a simple image from each material.
879 """
880 # extract an image for each material
881 img = None
882 if isinstance(mat, PBRMaterial):
883 if mat.metallicRoughnessTexture is not None:
884 if mat.metallicRoughnessTexture.format == "BGR":
885 img = np.array(mat.metallicRoughnessTexture.convert("RGB"))
886 else:
887 img = np.array(mat.metallicRoughnessTexture)
889 if len(img.shape) == 2 or img.shape[-1] == 1:
890 img = img.reshape(*img.shape[:2], 1)
891 img = np.concatenate(
892 [
893 img,
894 np.ones_like(img[..., :1]) * 255,
895 np.zeros_like(img[..., :1]),
896 ],
897 axis=-1,
898 )
899 elif img.shape[-1] == 2:
900 img = np.concatenate([img, np.zeros_like(img[..., :1])], axis=-1)
902 if mat.metallicFactor is not None:
903 img[..., 0] = np.round(
904 img[..., 0].astype(np.float64) * mat.metallicFactor
905 ).astype(np.uint8)
906 if mat.roughnessFactor is not None:
907 img[..., 1] = np.round(
908 img[..., 1].astype(np.float64) * mat.roughnessFactor
909 ).astype(np.uint8)
910 img = Image.fromarray(img)
911 else:
912 metallic = 0.0 if mat.metallicFactor is None else mat.metallicFactor
913 roughness = 1.0 if mat.roughnessFactor is None else mat.roughnessFactor
914 # glTF expects B=metallic, G=roughness, R=unused
915 # https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#metallic-roughness-material
916 metallic_roughnesss = np.round(
917 np.array([0.0, roughness, metallic], dtype=np.float64) * 255
918 )
919 img = Image.fromarray(metallic_roughnesss[None, None].astype(np.uint8))
920 return img
922 def get_emissive_texture(mat):
923 """
924 Logic for extracting a simple image from each material.
925 """
926 # extract an image for each material
927 img = None
928 if isinstance(mat, PBRMaterial):
929 if mat.emissiveTexture is not None:
930 img = multiply_factor(mat.emissiveTexture, mat.emissiveFactor, "RGB")
931 elif mat.emissiveFactor is not None:
932 c = color.to_rgba(mat.emissiveFactor)
933 img = Image.fromarray(c.reshape((1, 1, -1)))
934 else:
935 img = Image.fromarray(np.reshape([0, 0, 0], (1, 1, 3)).astype(np.uint8))
936 # make sure we're always returning in RGBA mode
937 return img.convert("RGB")
939 def get_normal_texture(mat):
940 # there is no default normal texture
941 return getattr(mat, "normalTexture", None)
943 def get_occlusion_texture(mat):
944 occlusion_texture = getattr(mat, "occlusionTexture", None)
945 if occlusion_texture is None:
946 occlusion_texture = Image.fromarray(np.array([[255]], dtype=np.uint8))
947 else:
948 occlusion_texture = occlusion_texture.convert("L")
949 return occlusion_texture
951 def resize_images(images, sizes):
952 resized = []
953 for img, size in zip(images, sizes):
954 if img is None:
955 resized.append(None)
956 else:
957 img = img.resize(size)
958 resized.append(img)
959 return resized
961 packed = {}
963 def pack_images(images):
964 # run image packing with our material-specific settings
965 # Note: deduplication is disabled to ensure consistent packing
966 # across different texture types (base color, metallic/roughness, etc)
968 # see if we've already run this packing image
969 key = hash(tuple(sorted([id(i) for i in images])))
970 assert key not in packed
971 if key in packed:
972 return packed[key]
974 # otherwise run packing now
975 result = packing.images(
976 images,
977 deduplicate=False, # Disabled to ensure consistent texture layouts
978 power_resize=True,
979 seed=42,
980 iterations=10,
981 spacing=int(padding),
982 )
983 packed[key] = result
984 return result
986 if deduplicate:
987 # start by collecting a list of indexes for each material hash
988 unique_idx = collections.defaultdict(list)
989 [unique_idx[hash(m)].append(i) for i, m in enumerate(materials)]
990 # now we only need the indexes and don't care about the hashes
991 mat_idx = list(unique_idx.values())
992 else:
993 # otherwise just use all the indexes
994 mat_idx = np.arange(len(materials)).reshape((-1, 1))
996 if len(mat_idx) == 1:
997 # if there is only one material we can just return it
998 return materials[0], np.vstack(uvs)
1000 assert set(np.concatenate(mat_idx).ravel()) == set(range(len(uvs)))
1001 assert len(uvs) == len(materials)
1002 use_pbr = any(isinstance(m, PBRMaterial) for m in materials)
1004 # in some cases, the fused scene results in huge trimsheets
1005 # we can try to prevent this by downscaling the textures iteratively
1006 down_scale_iterations = 6
1007 while down_scale_iterations > 0:
1008 # collect the images from the materials
1009 images = [get_base_color_texture(materials[g[0]]) for g in mat_idx]
1011 if use_pbr:
1012 # if we have PBR materials, collect all possible textures and
1013 # determine the largest size per material
1014 metallic_roughness = [
1015 get_metallic_roughness_texture(materials[g[0]]) for g in mat_idx
1016 ]
1017 emissive = [get_emissive_texture(materials[g[0]]) for g in mat_idx]
1018 normals = [get_normal_texture(materials[g[0]]) for g in mat_idx]
1019 occlusion = [get_occlusion_texture(materials[g[0]]) for g in mat_idx]
1021 unpadded_sizes = []
1022 for textures in zip(images, metallic_roughness, emissive, normals, occlusion):
1023 # remove None textures
1024 textures = [tex for tex in textures if tex is not None]
1025 tex_sizes = np.stack([np.array(tex.size) for tex in textures])
1026 max_tex_size = tex_sizes.max(axis=0)
1027 if max_tex_size.max() > max_tex_size_individual:
1028 scale = max_tex_size.max() / max_tex_size_individual
1029 max_tex_size = np.round(max_tex_size / scale).astype(np.int64)
1031 unpadded_sizes.append(tuple(max_tex_size))
1033 # use the same size for all of them to ensure
1034 # that texture atlassing is identical
1035 images = resize_images(images, unpadded_sizes)
1036 metallic_roughness = resize_images(metallic_roughness, unpadded_sizes)
1037 emissive = resize_images(emissive, unpadded_sizes)
1038 normals = resize_images(normals, unpadded_sizes)
1039 occlusion = resize_images(occlusion, unpadded_sizes)
1040 else:
1041 # for non-pbr materials, just use the original image size
1042 unpadded_sizes = []
1043 for img in images:
1044 tex_size = np.array(img.size)
1045 if tex_size.max() > max_tex_size_individual:
1046 scale = tex_size.max() / max_tex_size_individual
1047 tex_size = np.round(tex_size / scale).astype(np.int64)
1048 unpadded_sizes.append(tex_size)
1050 # pack the multiple images into a single large image
1051 final, offsets = pack_images(images)
1053 # if the final image is too large, reduce the maximum texture size and repeat
1054 if (
1055 max_tex_size_fused is not None
1056 and final.size[0] * final.size[1] > max_tex_size_fused**2
1057 ):
1058 down_scale_iterations -= 1
1059 max_tex_size_individual //= 2
1060 else:
1061 break
1063 if use_pbr:
1064 # even if we only need the first two channels, store RGB, because
1065 # PIL 'LA' mode images are interpreted incorrectly in other 3D software
1066 final_metallic_roughness, _ = pack_images(metallic_roughness)
1068 if all(np.array(x).max() == 0 for x in emissive):
1069 # if all emissive textures are black, don't use emissive
1070 emissive = None
1071 final_emissive = None
1072 else:
1073 final_emissive, _ = pack_images(emissive)
1075 if all(n is not None for n in normals):
1076 # only use normal texture if all materials use them
1077 # how else would you handle missing normals?
1078 final_normals, _ = pack_images(normals)
1079 else:
1080 final_normals = None
1082 if any(np.array(o).min() < 255 for o in occlusion):
1083 # only use occlusion texture if any material actually has an occlusion value
1084 final_occlusion, _ = pack_images(occlusion)
1085 else:
1086 final_occlusion = None
1088 # the size of the final texture image
1089 final_size = np.array(final.size, dtype=np.float64)
1090 # collect scaled new UV coordinates by material index
1091 new_uv = {}
1092 for group, img, offset in zip(mat_idx, images, offsets):
1093 # how big was the original image
1094 uv_scale = (np.array(img.size) - 1) / final_size
1095 # the units of offset are *pixels of the final image*
1096 # thus to scale them to normalized UV coordinates we
1097 # what is the offset in fractions of final image
1098 uv_offset = offset / (final_size - 1)
1099 # scale and translate each of the new UV coordinates
1100 # also make sure they are in 0.0-1.0 using modulus (i.e. wrap)
1101 half = 0.5 / np.array(img.size)
1103 for g in group:
1104 # only wrap pixels that are outside of 0.0-1.0.
1105 # use a small leeway of half a pixel for floating point inaccuracies and
1106 # the case of uv==1.0
1107 uvg = uvs[g].copy()
1109 # now wrap anything more than half a pixel outside
1110 uvg[np.logical_or(uvg < -half, uvg > (1.0 + half))] %= 1.0
1111 # clamp to half a pixel
1112 uvg = np.clip(uvg, half, 1.0 - half)
1114 # apply the scale and offset
1115 moved = (uvg * uv_scale) + uv_offset
1117 if tol.strict:
1118 # the color from the original coordinates and image
1119 old = color.uv_to_interpolated_color(uvs[g], img)
1120 # the color from the packed image
1121 new = color.uv_to_interpolated_color(moved, final)
1122 assert np.allclose(old, new, atol=6)
1124 new_uv[g] = moved
1126 # stack the new UV coordinates in the original order
1127 stacked = np.vstack([new_uv[i] for i in range(len(uvs))])
1129 if use_pbr:
1130 return (
1131 PBRMaterial(
1132 baseColorTexture=final,
1133 metallicRoughnessTexture=final_metallic_roughness,
1134 emissiveTexture=final_emissive,
1135 emissiveFactor=[1.0, 1.0, 1.0] if final_emissive else None,
1136 alphaMode=None, # unfortunately, we can't handle alpha blending well
1137 doubleSided=False, # TODO how to handle this?
1138 normalTexture=final_normals,
1139 occlusionTexture=final_occlusion,
1140 ),
1141 stacked,
1142 )
1143 else:
1144 return SimpleMaterial(image=final), stacked