from __future__ import annotations import json import sys import tempfile import unittest from pathlib import Path from PIL import Image TOOLS_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(TOOLS_ROOT)) import combine_substance_textures as combine # noqa: E402 class CombineSubstanceTexturesTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory(prefix="substance-combine-test-") self.root = Path(self.temp_dir.name) self.input_dir = self.root / "exports" self.output_dir = self.root / "combined" self.input_dir.mkdir() self.id_map = self.root / "Material_ID.png" id_image = Image.new("RGB", (4, 2), (255, 0, 0)) for y in range(2): for x in range(2, 4): id_image.putpixel((x, y), (0, 255, 0)) id_image.save(self.id_map) self.legend = self.root / "Material_ID.json" self.legend.write_text( json.dumps( { "materials": [ {"slot": 1, "material": "Left Metal", "rgb": [1.0, 0.0, 0.0]}, {"slot": 2, "material": "Right Paint", "rgb": [0.0, 1.0, 0.0]}, ] } ), encoding="utf-8", ) def tearDown(self) -> None: self.temp_dir.cleanup() def write_texture(self, name: str, color: tuple[int, int, int]) -> None: Image.new("RGB", (4, 2), color).save(self.input_dir / name) def test_combines_multiple_channels_by_id_color(self) -> None: self.write_texture("Robot_Left_Metal_BaseColor.png", (180, 20, 10)) self.write_texture("Robot_Right_Paint_BaseColor.png", (10, 40, 220)) self.write_texture("Robot_Left_Metal_Normal.png", (100, 120, 255)) self.write_texture("Robot_Right_Paint_Normal.png", (140, 125, 250)) outputs, unmatched = combine.combine_all( id_map_path=self.id_map, legend_path=self.legend, input_dir=self.input_dir, output_dir=self.output_dir, ) self.assertEqual(unmatched, []) self.assertEqual( {path.name for path in outputs}, {"Robot_BaseColor.png", "Robot_Normal.png"}, ) with Image.open(self.output_dir / "Robot_BaseColor.png") as result: self.assertEqual(result.getpixel((0, 0)), (180, 20, 10)) self.assertEqual(result.getpixel((3, 1)), (10, 40, 220)) def test_aliases_and_missing_materials(self) -> None: self.write_texture("asset_left_roughness.png", (32, 32, 32)) materials = combine.load_legend( self.legend, {"left metal": "left", "right paint": "right"}, ) groups, unmatched = combine.discover_groups(self.input_dir, materials) self.assertEqual(unmatched, []) self.assertEqual(len(groups), 1) self.assertEqual(groups[0].output_name, "asset_roughness.png") self.assertEqual(set(groups[0].textures), {"left metal"}) def test_rejects_mismatched_resolution(self) -> None: Image.new("RGB", (8, 8), (1, 2, 3)).save( self.input_dir / "Left_Metal_BaseColor.png" ) with self.assertRaisesRegex(combine.CombineError, "ID map"): combine.combine_all( id_map_path=self.id_map, legend_path=self.legend, input_dir=self.input_dir, output_dir=self.output_dir, ) def test_rejects_overlapping_tolerant_masks(self) -> None: self.legend.write_text( json.dumps( { "materials": [ {"slot": 1, "material": "Left", "rgb": [1 / 255, 0.0, 0.0]}, {"slot": 2, "material": "Right", "rgb": [2 / 255, 0.0, 0.0]}, ] } ), encoding="utf-8", ) Image.new("RGB", (4, 2), (1, 0, 0)).save(self.id_map) self.write_texture("Left_BaseColor.png", (10, 10, 10)) self.write_texture("Right_BaseColor.png", (20, 20, 20)) with self.assertRaisesRegex(combine.CombineError, "overlaps"): combine.combine_all( id_map_path=self.id_map, legend_path=self.legend, input_dir=self.input_dir, output_dir=self.output_dir, tolerance=1, ) def test_auto_tolerance_handles_float_to_png_rounding(self) -> None: self.legend.write_text( json.dumps( { "materials": [ {"slot": 1, "material": "Left", "rgb": [0.1, 0.2, 1.0]}, ] } ), encoding="utf-8", ) # Python rounds 0.1 * 255 to 26, while Blender may store it as 25. Image.new("RGB", (4, 2), (25, 51, 255)).save(self.id_map) self.write_texture("Left_BaseColor.png", (12, 34, 56)) outputs, _ = combine.combine_all( id_map_path=self.id_map, legend_path=self.legend, input_dir=self.input_dir, output_dir=self.output_dir, ) with Image.open(outputs[0]) as result: self.assertEqual(result.getpixel((0, 0)), (12, 34, 56)) def test_recursive_material_directories_are_grouped(self) -> None: left_dir = self.input_dir / "Robot_Left_Metal" right_dir = self.input_dir / "Robot_Right_Paint" left_dir.mkdir() right_dir.mkdir() Image.new("L", (4, 2), 40).save(left_dir / "Roughness.png") Image.new("L", (4, 2), 210).save(right_dir / "Roughness.png") outputs, _ = combine.combine_all( id_map_path=self.id_map, legend_path=self.legend, input_dir=self.input_dir, output_dir=self.output_dir, recursive=True, ) self.assertEqual(outputs[0].relative_to(self.output_dir), Path("Robot/Roughness.png")) with Image.open(outputs[0]) as result: self.assertEqual(result.getpixel((0, 0)), 40) self.assertEqual(result.getpixel((3, 1)), 210) if __name__ == "__main__": unittest.main()