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

1""" 

2material.py 

3------------- 

4 

5Store visual materials as objects. 

6""" 

7 

8import abc 

9import copy 

10 

11import numpy as np 

12 

13from .. import exceptions, util 

14from ..constants import tol 

15from ..typed import NDArray 

16from . import color 

17 

18try: 

19 from PIL import Image 

20except BaseException as E: 

21 Image = exceptions.ExceptionWrapper(E) 

22 

23# epsilon for comparing floating point 

24_eps = 1e-5 

25 

26 

27class Material(util.ABC): 

28 def __init__(self, *args, **kwargs): 

29 raise NotImplementedError("must be subclassed!") 

30 

31 @abc.abstractmethod 

32 def __hash__(self): 

33 raise NotImplementedError("must be subclassed!") 

34 

35 @property 

36 @abc.abstractmethod 

37 def main_color(self): 

38 """ 

39 The "average" color of this material. 

40 

41 Returns 

42 --------- 

43 color : (4,) uint8 

44 Average color of this material. 

45 """ 

46 

47 @property 

48 def name(self): 

49 if hasattr(self, "_name"): 

50 return self._name 

51 return "material_0" 

52 

53 @name.setter 

54 def name(self, value): 

55 if value is not None: 

56 self._name = value 

57 

58 def copy(self): 

59 return copy.deepcopy(self) 

60 

61 

62class SimpleMaterial(Material): 

63 """ 

64 Hold a single image texture. 

65 """ 

66 

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) 

84 

85 # save Ns 

86 self.glossiness = glossiness 

87 

88 # save other keyword arguments 

89 self.kwargs = kwargs 

90 

91 def to_color(self, uv): 

92 return color.uv_to_color(uv, self.image) 

93 

94 def to_obj(self, name=None): 

95 """ 

96 Convert the current material to an OBJ format 

97 material. 

98 

99 Parameters 

100 ----------- 

101 name : str or None 

102 Name to apply to the material 

103 

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] 

117 

118 if name is None: 

119 name = self.name 

120 

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 ] 

129 

130 # collect the OBJ data into files 

131 data = {} 

132 

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

141 

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

147 

148 data[f"{name}.mtl"] = "\n".join(mtl).encode("utf-8") 

149 

150 return data, name 

151 

152 def __hash__(self): 

153 """ 

154 Provide a hash of the material so we can detect 

155 duplicates. 

156 

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 

180 

181 @property 

182 def main_color(self): 

183 """ 

184 Return the most prominent color. 

185 """ 

186 return self.diffuse 

187 

188 @property 

189 def glossiness(self): 

190 if hasattr(self, "_glossiness"): 

191 return self._glossiness 

192 return 1.0 

193 

194 @glossiness.setter 

195 def glossiness(self, value): 

196 if value is None: 

197 return 

198 self._glossiness = float(value) 

199 

200 def to_pbr(self): 

201 """ 

202 Convert the current simple material to a 

203 PBR material. 

204 

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) 

212 

213 return PBRMaterial( 

214 roughnessFactor=roughness, 

215 baseColorTexture=self.image, 

216 baseColorFactor=self.diffuse, 

217 ) 

218 

219 

220class MultiMaterial(Material): 

221 def __init__(self, materials=None, **kwargs): 

222 """ 

223 Wrapper for a list of Materials. 

224 

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 

234 

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] 

243 

244 def __hash__(self): 

245 """ 

246 Provide a hash of the multi material so we can detect 

247 duplicates. 

248 

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

255 

256 def __iter__(self): 

257 return iter(self.materials) 

258 

259 def __next__(self): 

260 return next(self.materials) 

261 

262 def __len__(self): 

263 return len(self.materials) 

264 

265 @property 

266 def main_color(self): 

267 """ 

268 The "average" color of this material. 

269 

270 Returns 

271 --------- 

272 color : (4,) uint8 

273 Average color of this material. 

274 """ 

275 

276 def add(self, material): 

277 """ 

278 Adds new material to the container. 

279 

280 Parameters 

281 ---------- 

282 material : Material 

283 The material to be added. 

284 """ 

285 self.materials.append(material) 

286 

287 def get(self, idx): 

288 """ 

289 Get material by index. 

290 

291 Parameters 

292 ---------- 

293 idx : int 

294 Index of the material to be retrieved. 

295 

296 Returns 

297 ------- 

298 The material on the given index. 

299 """ 

300 return self.materials[idx] 

301 

302 

303class PBRMaterial(Material): 

304 """ 

305 Create a material for physically based rendering as 

306 specified by GLTF 2.0: 

307 https://git.io/fhkPZ 

308 

309 Parameters with `Texture` in them must be PIL.Image objects 

310 """ 

311 

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 = {} 

331 

332 # (3,) float 

333 self.emissiveFactor = emissiveFactor 

334 # (3,) or (4,) float with RGBA colors 

335 self.baseColorFactor = baseColorFactor 

336 

337 # float 

338 self.metallicFactor = metallicFactor 

339 self.roughnessFactor = roughnessFactor 

340 self.alphaCutoff = alphaCutoff 

341 

342 # PIL image 

343 self.normalTexture = normalTexture 

344 self.emissiveTexture = emissiveTexture 

345 self.occlusionTexture = occlusionTexture 

346 self.baseColorTexture = baseColorTexture 

347 self.metallicRoughnessTexture = metallicRoughnessTexture 

348 

349 # bool 

350 self.doubleSided = doubleSided 

351 

352 # str 

353 self.name = name 

354 self.alphaMode = alphaMode 

355 

356 if len(kwargs) > 0: 

357 util.log.debug( 

358 "unsupported material keys: {}".format(", ".join(kwargs.keys())) 

359 ) 

360 

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. 

367 

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

375 

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 

387 

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. 

394 

395 Returns 

396 ----------- 

397 alphaMode : str 

398 One of 'OPAQUE', 'MASK', 'BLEND' 

399 """ 

400 return self._data.get("alphaMode") 

401 

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 

413 

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. 

424 

425 Returns 

426 ----------- 

427 alphaCutoff : float 

428 Value of cutoff. 

429 """ 

430 return self._data.get("alphaCutoff") 

431 

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) 

439 

440 @property 

441 def doubleSided(self): 

442 """ 

443 Specifies whether the material is double sided. 

444 

445 Returns 

446 ----------- 

447 doubleSided : bool 

448 Specifies whether the material is double sided. 

449 """ 

450 return self._data.get("doubleSided") 

451 

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) 

459 

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. 

466 

467 

468 Returns 

469 ----------- 

470 metallicFactor : float 

471 How metally is the material 

472 """ 

473 return self._data.get("metallicFactor") 

474 

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) 

482 

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. 

489 

490 Returns 

491 ----------- 

492 roughnessFactor : float 

493 Roughness of material. 

494 """ 

495 return self._data.get("roughnessFactor") 

496 

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) 

504 

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. 

511 

512 Returns 

513 --------- 

514 color : (4,) uint8 

515 RGBA color 

516 """ 

517 return self._data.get("baseColorFactor") 

518 

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) 

527 

528 @property 

529 def normalTexture(self): 

530 """ 

531 The normal map texture. 

532 

533 Returns 

534 ---------- 

535 image : PIL.Image 

536 Normal texture. 

537 """ 

538 return self._data.get("normalTexture") 

539 

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 

547 

548 @property 

549 def emissiveTexture(self): 

550 """ 

551 The emissive texture. 

552 

553 Returns 

554 ---------- 

555 image : PIL.Image 

556 Emissive texture. 

557 """ 

558 return self._data.get("emissiveTexture") 

559 

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 

567 

568 @property 

569 def occlusionTexture(self): 

570 """ 

571 The occlusion texture. 

572 

573 Returns 

574 ---------- 

575 image : PIL.Image 

576 Occlusion texture. 

577 """ 

578 return self._data.get("occlusionTexture") 

579 

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 

587 

588 @property 

589 def baseColorTexture(self): 

590 """ 

591 The base color texture image. 

592 

593 Returns 

594 ---------- 

595 image : PIL.Image 

596 Color texture. 

597 """ 

598 return self._data.get("baseColorTexture") 

599 

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 

608 

609 @property 

610 def metallicRoughnessTexture(self): 

611 """ 

612 The metallic-roughness texture. 

613 

614 Returns 

615 ---------- 

616 image : PIL.Image 

617 Metallic-roughness texture. 

618 """ 

619 return self._data.get("metallicRoughnessTexture") 

620 

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 

628 

629 @property 

630 def name(self): 

631 return self._data.get("name") 

632 

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 

640 

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) 

655 

656 def to_color(self, uv): 

657 """ 

658 Get the rough color at a list of specified UV 

659 coordinates. 

660 

661 Parameters 

662 ------------- 

663 uv : (n, 2) float 

664 UV coordinates on the material 

665 

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 

674 

675 def to_simple(self): 

676 """ 

677 Get a copy of the current PBR material as 

678 a simple material. 

679 

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 ) 

697 

698 @property 

699 def main_color(self): 

700 # will return default color if None 

701 result = color.to_rgba(self.baseColorFactor) 

702 return result 

703 

704 def __hash__(self): 

705 """ 

706 Provide a hash of the material so we can detect 

707 duplicate materials. 

708 

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 ) 

719 

720 

721def empty_material(color: NDArray[np.uint8] | None = None) -> SimpleMaterial: 

722 """ 

723 Return an empty material set to a single color 

724 

725 Parameters 

726 ----------- 

727 color : None or (3,) uint8 

728 RGB color 

729 

730 Returns 

731 ------------- 

732 material : SimpleMaterial 

733 Image is a a four pixel RGB 

734 """ 

735 

736 # create a one pixel RGB image 

737 return SimpleMaterial(image=color_image(color=color)) 

738 

739 

740def color_image(color: NDArray[np.uint8] | None = None): 

741 """ 

742 Generate an image with one color. 

743 

744 Parameters 

745 ---------- 

746 color 

747 Optional uint8 color 

748 

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

763 

764 

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. 

775 

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

778 

779 Alpha blending and backface culling settings are not supported! 

780 Returns a material with alpha values set, but alpha blending disabled. 

781 

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. 

796 

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

804 

805 import collections 

806 

807 from PIL import Image 

808 

809 from ..path import packing 

810 

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) 

823 

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) 

843 

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 ) 

848 

849 # Convert to uint8 

850 c = np.round(c_srgb * 255).astype(np.uint8) 

851 assert c.shape == (4,) 

852 img = color_image(c) 

853 

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) 

869 

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

875 

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) 

888 

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) 

901 

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 

921 

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

938 

939 def get_normal_texture(mat): 

940 # there is no default normal texture 

941 return getattr(mat, "normalTexture", None) 

942 

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 

950 

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 

960 

961 packed = {} 

962 

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) 

967 

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] 

973 

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 

985 

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

995 

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) 

999 

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) 

1003 

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] 

1010 

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] 

1020 

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) 

1030 

1031 unpadded_sizes.append(tuple(max_tex_size)) 

1032 

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) 

1049 

1050 # pack the multiple images into a single large image 

1051 final, offsets = pack_images(images) 

1052 

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 

1062 

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) 

1067 

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) 

1074 

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 

1081 

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 

1087 

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) 

1102 

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

1108 

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) 

1113 

1114 # apply the scale and offset 

1115 moved = (uvg * uv_scale) + uv_offset 

1116 

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) 

1123 

1124 new_uv[g] = moved 

1125 

1126 # stack the new UV coordinates in the original order 

1127 stacked = np.vstack([new_uv[i] for i in range(len(uvs))]) 

1128 

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