Module kawa_scripts.atlas_baker

Tool for baking a lots of PBR materials on a lots of Objects into single texture atlas. See BaseAtlasBaker.

Expand source code
# Kawashirov's Scripts (c) 2021 by Sergey V. Kawashirov
#
# Kawashirov's Scripts is licensed under a
# Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
#
# You should have received a copy of the license along with this
# work.  If not, see <http://creativecommons.org/licenses/by-nc-sa/3.0/>.
#
#
"""
Tool for baking a lots of PBR materials on a lots of Objects into single texture atlas.
See `kawa_scripts.atlas_baker.BaseAtlasBaker`.
"""

from sys import maxsize as _int_maxsize
from gc import collect as _gc_collect
from time import perf_counter as _perf_counter
from random import shuffle as _shuffle

import bpy as _bpy
from bpy import data as _D
from bpy import context as _C
from bmesh import new as _bmesh_new
from mathutils import Vector as _Vector
from mathutils.geometry import box_pack_2d as _box_pack_2d

from . import commons as _commons
from . import uv as _uv
from . import shader_nodes as _snodes
from .reporter import LambdaReporter as _LambdaReporter
from ._internals import common_str_slots
from ._internals import log as _log

import typing as _typing

if _typing.TYPE_CHECKING:
        from typing import *
        from bpy.types import *
        from mathutils import Vector


class UVTransform:
        """
        Internal class used by `BaseAtlasBaker` as mapping between UV areas on original Materials and UV areas on atlas.
        """
        __slots__ = ('material', 'origin_norm', 'padded_norm', 'packed_norm')
        
        def __init__(self):
                # Хранить множество вариантов координат затратно по памяти,
                # но удобно в отладке и избавляет от велосипедов
                self.material = None  # type: Material
                # Оригинальная uv в нормальных координатах и в пикселях текстуры
                self.origin_norm = None  # type: Vector # len == 4
                # self.origin_tex = None  # type: Vector # len == 4
                # Оригинальная uv c отступами в нормальных и в пикселях текстуры
                self.padded_norm = None  # type: Vector # len == 4
                # self.padded_tex = None  # type: Vector # len == 4
                # packed использует промежуточные координаты во время упаковки,
                # использует нормализованные координаты после упаковки
                self.packed_norm = None  # type: Vector # len == 4
        
        def __str__(self) -> str: return common_str_slots(self, self.__slots__)
        
        def __repr__(self) -> str: return common_str_slots(self, self.__slots__)
        
        def is_match(self, vec2_norm: 'Vector', epsilon_x: 'float' = 0, epsilon_y: 'float' = 0):
                v = self.origin_norm
                x1, x2 = v.x - epsilon_x, v.x + v.z + epsilon_x
                y1, y2 = v.y - epsilon_y, v.y + v.w + epsilon_y
                return x1 <= vec2_norm.x <= x2 and y1 <= vec2_norm.y <= y2
        
        @staticmethod
        def _in_box(v2: 'Vector', box: 'Vector'):
                # Координаты vec2 внутри box как 0..1
                v2.x = (v2.x - box.x) / box.z
                v2.y = (v2.y - box.y) / box.w
        
        @staticmethod
        def _out_box(v2: 'Vector', box: 'Vector'):
                # Координаты 0..1 внутри box вне его
                v2.x = v2.x * box.z + box.x
                v2.y = v2.y * box.w + box.y
        
        def apply(self, vec2_norm: 'Vector') -> 'Vector':
                # Преобразование padded_norm -> packed_norm
                uv = vec2_norm.xy  # копирование
                self._in_box(uv, self.padded_norm)
                self._out_box(uv, self.packed_norm)
                return uv
        
        def iterate_corners(self) -> 'Generator[Tuple[int, Tuple[float, float]]]':
                # Обходу углов: #, оригинальная UV, атласная UV
                pd, pk = self.padded_norm, self.packed_norm
                yield 0, (pd.x, pd.y), (pk.x, pk.y)  # vert 0: left, bottom
                yield 1, (pd.x + pd.z, pd.y), (pk.x + pk.z, pk.y)  # vert 1: right, bottom
                yield 2, (pd.x + pd.z, pd.y + pd.w), (pk.x + pk.z, pk.y + pk.w)  # vert 2: right, up
                yield 3, (pd.x, pd.y + pd.w), (pk.x, pk.y + pk.w)  # vert 2: right, up


class BaseAtlasBaker:
        """
        Base class for Atlas Baking.
        You must extend this class with required and necessary methods for your case,
        configure variables and then run `bake_atlas`.
        """
        
        ISLAND_TYPES = ('POLYGON', 'OBJECT')
        """
        Available types of UV-Islands Searching for reference.
        Set per-Object and per-Material, see `get_island_mode`.
        
        - `'POLYGON'` will try to use every polygon provided to find rectangular UV areas of Material.
        Allows to detect separated UV parts from same material and put it separately and more efficient on atlas.
        Slower, but can result dense and efficient packing.
        
        - `'OBJECT'` will count each Material on each Object as single united island.
        Fast, but can pick large unused areas of Materials for atlasing and final atlas may be packed inefficient.
        """
        
        BAKE_TYPES = ('DIFFUSE', 'ALPHA', 'EMIT', 'NORMAL', 'ROUGHNESS', 'METALLIC')
        """
        Available types of baking layers for reference.
        Note, there is no DIFFUSE+ALPHA RGBA (use separate textures) and SMOOTHNESS (use ROUGHNESS instead) yet.
        """
        
        # Имена UV на ._bake_obj
        _UV_ORIGINAL = "UV-Original"
        _UV_ATLAS = "UV-Atlas"
        
        _PROC_ORIGINAL_UV_NAME = "__AtlasBaker_UV_Main_Original"
        _PROC_TARGET_ATLAS_UV_NAME = "__AtlasBaker_UV_Main_Target"
        _PROP_ORIGIN_OBJECT = "__AtlasBaker_OriginObject"
        _PROP_ORIGIN_MESH = "__AtlasBaker_OriginMesh"
        
        _PROC_NAME = "__AtlasBaker_Processing_"
        
        def __init__(self):
                self.objects = set()  # type: Set[Object]
                """ Mesh-Objects that will be atlassed. """
                
                self.target_size = (1, 1)  # type: Tuple[int, int]
                """
                Size of atlas. Actually used only as aspect ratio.
                Your target images (See `get_target_image`) must match this ratio.
                """
                
                self.padding = 4  # type: float
                """ Padding added to each UV Island around to avoid leaks. """
                
                self.report_time = 5
                """ Minimum time between progress reports into logfile when running long and heavy operations.  """
                
                # # # Внутренее # # #
                
                self._materials = dict()  # type: Dict[Tuple[Object, Material], Material]
                self._matsizes = dict()  # type: Dict[Material, Tuple[float, float]]
                self._bake_types = list()  # type: List[Tuple[str, Image]]
                # Объекты, скопированные для операций по поиску UV развёрток
                self._copies = set()  # type: Set[Object]
                # Группы объектов по материалам из ._copies
                self._groups = dict()  # type: Dict[Material, Set[Object]]
                # Острова UV найденые на материалах из ._groups
                self._islands = dict()  # type: Dict[Material, _uv.IslandsBuilder]
                # Преобразования, необходимые для получения нового UV для атласса
                self._transforms = dict()  # type: Dict[Material, List[UVTransform]]
                # Вспомогательный объект, необходимый для запекания атласа
                self._bake_obj = None  # type: Optional[Object]
                self._node_editor_override = False
        
        # # # Переопределяемые методы # # #
        
        def get_material_size(self, src_mat: 'Material') -> 'Optional[Tuple[float, float]]':
                """
                Must return size of material.
                This is relative compared with other Materials to figure out final area of Material on atlas.
                The real size of material will be different anyways.
                
                You can use `kawa_scripts.tex_size_finder.TexSizeFinder` here.
                """
                raise NotImplementedError('get_material_size')
        
        def get_target_material(self, origin: 'Object', src_mat: 'Material') -> 'Material':
                """
                Must return target Material for source Material.
                Ths source Material on this Object will be replaced with target Material after baking.
                Atlas Baker does not create final Materials by it's own.
                You should prepare target materials (with target images) and provide it here, so Atlas Baker can use and assign it.
                """
                raise NotImplementedError('get_target_material')
        
        def get_target_image(self, bake_type: str) -> 'Optional[Image]':
                """
                Must return target Image for given bake type.
                Atlas Baker will bake atlas onto this Image.
                If Image is not provided (None or False) this bake type will not be baked.
                See `BAKE_TYPES` for available bake types.
                """
                raise NotImplementedError('get_target_image')
        
        def get_uv_name(self, obj: 'Object', mat: 'Material') -> 'Opional[str]':
                """
                Should return the name of UV Layer of given Object and Material that will be used for baking.
                If not implemented (or returns None or False) first UV layer will be used.
                This layer will be edited to match Atlas and target Material.
                """
                return  # TODO
        
        def get_island_mode(self, origin: 'Object', mat: 'Material') -> 'str':
                """
                Must return one of island search types. See `ISLAND_TYPES` for details.
                """
                return 'POLYGON'
        
        def get_epsilon(self, obj: 'Object', mat: 'Material') -> 'Optional[float]':
                """
                Should return precision value in pixel-space for given Object and Material.
                Note, size obtained from `get_material_size` is used for pixel-space.
                """
                return None
        
        def before_bake(self, bake_type: str, target_image: 'Image'):
                """
                This method is called before baking given type and Image.
                Note, Image obtained from 'get_target_image' is used for `target_image`.
                You can prepare something here, for example, adjust Blender's baking settings.
                """
                pass
        
        def after_bake(self, bake_type: str, target_image: 'Image'):
                """
                This method is called after baking given type and Image.
                Note, Image obtained from 'get_target_image' is used for `target_image`.
                You can post-process something here, for example, save baked Image.
                """
                pass
        
        def _get_source_object(self, copy_obj: 'Object'):
                name = copy_obj.get(self._PROP_ORIGIN_OBJECT)
                origin_obj = _D.objects.get(name)
                return origin_obj
        
        def _get_matsize_safe(self, mat: 'Material') -> 'Tuple[float, float]':
                default_size = (16, 16)
                size = None
                try:
                        # TODO если размер текстуры не выявлен, нужно отрабатывать чётче
                        size = self.get_material_size(mat)
                        if not size:
                                size = default_size
                        if not isinstance(size, tuple) or len(size) != 2 or not isinstance(size[0], (int, float)) or not isinstance(size[1], (int, float)):
                                _log.warning("Material {0} have invalid material size: {1}".format(mat, repr(size)))
                                size = default_size
                        if size[0] <= 0 or size[1] <= 0:
                                _log.warning("Material {0} have invalid material size: {1}".format(mat, repr(size)))
                                size = default_size
                        return size
                except Exception as exc:
                        msg = 'Can not get size of material {0}.'.format(mat)
                        _log.error(msg)
                        raise RuntimeError(msg, mat, size) from exc
        
        def _get_epsilon_safe(self, obj: 'Object', mat: 'Material'):
                epsilon = None
                try:
                        epsilon = self.get_epsilon(obj, mat) or 1
                        return epsilon
                except Exception as exc:
                        msg = 'Can not get epsilon for {0} and {1}.'.format(obj, mat)
                        _log.error(msg)
                        raise RuntimeError(msg, obj, mat, epsilon) from exc
        
        def _get_uv_data_safe(self, obj: 'Object', mat: 'Material', mesh: 'Mesh'):
                uv_name = None
                try:
                        uv_name = self.get_uv_name(obj, mat) or 0
                        uv_data = mesh.uv_layers[uv_name].data  # type: List[MeshUVLoop]
                except Exception as exc:
                        msg = 'Can not get uv_layers[{2}] data for {0} and {1}.'.format(obj, mat, uv_name)
                        _log.error(msg)
                        raise RuntimeError(msg, obj, mat, mesh, uv_name) from exc
                return uv_data
        
        def _get_bake_image_safe(self, bake_type: str):
                image = None
                try:
                        image = self.get_target_image(bake_type)
                        # ...
                        return image
                except Exception as exc:
                        msg = 'Can not get image for bake type {0}.'.format(bake_type)
                        _log.error(msg)
                        raise RuntimeError(msg, bake_type, image) from exc
        
        def _prepare_objects(self):
                objects = set()
                for obj in self.objects:
                        if not isinstance(obj.data, _bpy.types.Mesh):
                                _log.warning("{0} is not a valid mesh-object!".format(obj))
                                continue
                        objects.add(obj)
                self.objects = objects
        
        def _prepare_target_images(self):
                for bake_type in self.BAKE_TYPES:
                        target_image = self._get_bake_image_safe(bake_type)
                        if target_image is None or target_image is False:
                                continue
                        self._bake_types.append((bake_type, target_image))
        
        def _prepare_materials(self):
                for obj in self.objects:
                        for slot in obj.material_slots:  # type: MaterialSlot
                                if slot is None or slot.material is None:
                                        _log.warning("Empty material slot detected: {0}".format(obj))
                                        continue
                                tmat = self._get_target_material_safe(obj, slot.material)
                                if isinstance(tmat, _bpy.types.Material):
                                        self._materials[(obj, slot.material)] = tmat
                mats = set(x[1] for x in self._materials.keys())
                _log.info("Validating {0} source materials...".format(len(mats)))
                for mat in mats:
                        self._check_material(mat)
                _log.info("Validated {0} source materials.".format(len(mats)))
        
        def _prepare_matsizes(self):
                mat_i = 0
                smats = set(x[1] for x in self._materials.keys())
                
                reporter = _LambdaReporter(self.report_time)
                reporter.func = lambda r, t: _log.info(
                        "Preparing material sizes, Materials={0}/{1}, Time={2:.1f} sec, ETA={3:.1f} sec...".format(
                                mat_i, len(smats), t, r.get_eta(1.0 * mat_i / len(smats))))
                
                for smat in smats:
                        self._matsizes[smat] = self._get_matsize_safe(smat)
                        mat_i += 1
                        reporter.ask_report(False)
                reporter.ask_report(True)
        
        def _make_duplicates(self):
                # Делает дубликаты объектов, сохраняет в ._copies
                _log.info("Duplicating temp objects for atlasing...")
                _commons.ensure_deselect_all_objects()
                for obj in self.objects:
                        if isinstance(obj.data, _bpy.types.Mesh):
                                _commons.activate_object(obj)
                        obj[self._PROP_ORIGIN_OBJECT] = obj.name
                        obj.data[self._PROP_ORIGIN_MESH] = obj.data.name
                _commons.ensure_op_finished(_bpy.ops.object.duplicate(linked=False), name='bpy.ops.object.duplicate')
                self._copies.update(_C.selected_objects)
                # Меченые имена, что бы если скрипт крашнется сразу было их видно
                for obj in self._copies:
                        obj_name = obj.get(self._PROP_ORIGIN_OBJECT) or 'None'
                        mesh_name = obj.data.get(self._PROP_ORIGIN_MESH) or 'None'
                        obj.name = self._PROC_NAME + obj_name
                        obj.data.name = self._PROC_NAME + mesh_name
                _log.info("Duplicated {0} temp objects for atlasing.".format(len(self._copies)))
                _commons.ensure_deselect_all_objects()
        
        def _separate_duplicates(self):
                # Разбивает дупликаты по материалам
                _log.info("Separating temp objects for atlasing...")
                _commons.ensure_deselect_all_objects()
                _commons.activate_objects(self._copies)
                _commons.ensure_op_finished(_bpy.ops.mesh.separate(type='MATERIAL'), name='bpy.ops.mesh.separate')
                count = len(self._copies)
                self._copies.update(_C.selected_objects)
                _commons.ensure_deselect_all_objects()
                _log.info("Separated {0} -> {1} temp objects".format(count, len(self._copies)))
        
        def _get_single_material(self, obj: 'Object') -> 'Material':
                ms_c = len(obj.material_slots)
                if ms_c != 1:  # TODO
                        raise RuntimeError("ms_c != 1", obj)
                slot = obj.material_slots[0]  # type: MaterialSlot
                mat = slot.material if slot is not None else None
                if mat is None:  # TODO
                        raise RuntimeError("mat is None", obj)
                return mat
        
        def _cleanup_duplicates(self):
                # Удаляет те материалы, которые не будут атлассироваться
                source_materials = list(x[1] for x in self._materials.keys())
                to_delete = set()
                for cobj in self._copies:
                        sobj = self._get_source_object(cobj)
                        smat = self._get_single_material(cobj)
                        tmat = self._materials.get((sobj, smat))
                        if tmat is None or tmat is False:
                                to_delete.add(cobj)
                _commons.ensure_deselect_all_objects()
                for cobj in to_delete:
                        cobj.hide_set(False)
                        cobj.select_set(True)
                _commons.ensure_op_finished(_bpy.ops.object.delete(use_global=True, confirm=True), name='bpy.ops.object.delete')
                if len(_C.selected_objects) > 0:  # TODO
                        raise RuntimeError("len(bpy.context.selected_objects) > 0", list(_C.selected_objects))
                for cobj in to_delete:
                        self._copies.discard(cobj)
                _log.info("Removed {0} temp objects, left {1} objects.".format(len(to_delete), len(self._copies)))
        
        def _group_duplicates(self):
                # Группирует self._copies по материалам в self._groups
                for obj in self._copies:
                        mat = self._get_single_material(obj)
                        group = self._groups.get(mat)
                        if group is None:
                                group = set()
                                self._groups[mat] = group
                        group.add(obj)
                _log.info("Grouped {0} temp objects into %d material groups.".format(len(self._copies), len(self._groups)))
        
        def _find_islands(self):
                mat_i, obj_i = 0, 0
                
                def do_report(r, t):
                        islands = sum(len(x.bboxes) for x in self._islands.values())
                        merges = sum(x.merges for x in self._islands.values())
                        _log.info("Searching UV islands: Objects={0}/{1}, Materials={2}/{3}, Islands={4}, Merges={5}, Time={6:.1f} sec, ETA={7:.1f} sec..."
                                .format(obj_i, len(self._copies), mat_i, len(self._groups), islands, merges, t, r.get_eta(1.0 * obj_i / len(self._copies))))
                        
                reporter = _LambdaReporter(self.report_time)
                reporter.func = do_report
                
                _log.info("Searching islands...")
                # Поиск островов, наполнение self._islands
                for mat, group in self._groups.items():
                        # log.info("Searching islands of material %s in %d objects...", mat.name, len(group))
                        mat_size_x, mat_size_y = self._matsizes.get(mat)
                        builder = _commons.dict_get_or_add(self._islands, mat, _uv.IslandsBuilder)
                        for obj in group:
                                origin = self._get_source_object(obj)
                                mesh = _commons.get_mesh_safe(obj)
                                epsilon = self._get_epsilon_safe(origin, mat)
                                uv_data = self._get_uv_data_safe(origin, mat, mesh)
                                
                                polygons = list(mesh.polygons)  # type: List[MeshPolygon]
                                # Оптимизация. Сортировка от большей площади к меньшей,
                                # что бы сразу сделать большие боксы и реже пере-расширять их.
                                polygons.sort(key=lambda p: _uv.uv_area(p, uv_data), reverse=True)
                                
                                mode = self.get_island_mode(origin, mat)
                                if mode == 'OBJECT':
                                        # Режим одного острова: все точки всех полигонов формируют общий bbox
                                        vec2s = list()
                                        for poly in polygons:
                                                for loop in poly.loop_indices:
                                                        vec2 = uv_data[loop].uv.xy  # type: Vector
                                                        # Преобразование в размеры текстуры
                                                        vec2.x *= mat_size_x
                                                        vec2.y *= mat_size_y
                                                        vec2s.append(vec2)
                                        builder.add_seq(vec2s, epsilon=epsilon)
                                elif mode == 'POLYGON':
                                        # Режим многих островов: каждый полигон формируют свой bbox
                                        try:
                                                for poly in polygons:
                                                        vec2s = list()
                                                        for loop in poly.loop_indices:  # type: int
                                                                vec2 = uv_data[loop].uv.xy  # type: Vector
                                                                # Преобразование в размеры текстуры
                                                                vec2.x *= mat_size_x
                                                                vec2.y *= mat_size_y
                                                                vec2s.append(vec2)
                                                        builder.add_seq(vec2s, epsilon=epsilon)
                                        except Exception as exc:
                                                raise RuntimeError("Error searching multiple islands!", uv_data, obj, mat, mesh, builder) from exc
                                else:
                                        raise RuntimeError('Invalid mode', mode)
                                obj_i += 1
                                reporter.ask_report(False)
                        mat_i += 1
                        reporter.ask_report(False)
                reporter.ask_report(True)
                
                # for mat, builder in self._islands.items():
                #       log.info("\tMaterial %s have %d islands:", mat, len(builder.bboxes))
                #       for bbox in builder.bboxes:
                #               log.info("\t\t%s", str(bbox))
                # В процессе работы с остравами мы могли настрать много мусора,
                # можно явно от него избавиться
                _gc_collect()
                pass
        
        def _delete_groups(self):
                count = len(self._copies)
                _log.info("Removing {0} temp objects...".format(count))
                _commons.ensure_deselect_all_objects()
                for obj in self._copies:
                        obj.hide_set(False)
                        obj.select_set(True)
                _commons.ensure_op_finished(_bpy.ops.object.delete(use_global=True, confirm=True), name='bpy.ops.object.delete')
                if len(_C.selected_objects) > 0:  # TODO
                        raise RuntimeError("len(bpy.context.selected_objects) > 0", list(_C.selected_objects))
                _log.info("Removed {0} temp objects.".format(count))
        
        def _create_transforms_from_islands(self):
                # Преобразует острава в боксы в формате mathutils.geometry.box_pack_2d
                for mat, builder in self._islands.items():
                        for bbox in builder.bboxes:
                                if not bbox.is_valid():
                                        raise ValueError("box is invalid: ", bbox, mat, builder, builder.bboxes)
                                origin_w, origin_h = self._matsizes[mat]
                                t = UVTransform()
                                t.material = mat
                                # две точки -> одна точка + размер
                                x, w = bbox.mn.x, (bbox.mx.x - bbox.mn.x)
                                y, h = bbox.mn.y, (bbox.mx.y - bbox.mn.y)
                                # t.origin_tex = (x, y, w, h)
                                t.origin_norm = _Vector((x / origin_w, y / origin_h, w / origin_w, h / origin_h))
                                # добавляем отступы
                                xp, yp = x - self.padding, y - self.padding,
                                wp, hp = w + 2 * self.padding, h + 2 * self.padding
                                # meta.padded_tex = (xp, yp, wp, hp)
                                t.padded_norm = _Vector((xp / origin_w, yp / origin_h, wp / origin_w, hp / origin_h))
                                # Координаты для упаковки
                                # Т.к. box_pack_2d пытается запаковать в квадрат, а у нас может быть текстура любой формы,
                                # то необходимо скорректировать пропорции
                                xb, yb = xp / self.target_size[0], yp / self.target_size[1]
                                wb, hb = wp / self.target_size[0], hp / self.target_size[1]
                                t.packed_norm = _Vector((xb, yb, wb, hb))
                                metas = _commons.dict_get_or_add(self._transforms, mat, list)
                                metas.append(t)
        
        def _pack_islands(self):
                # Несколько итераций перепаковки
                # TODO вернуть систему с раундами
                boxes = list()  # type: List[List[Union[float, UVTransform]]]
                for metas in self._transforms.values():
                        for meta in metas:
                                boxes.append([*meta.packed_norm, meta])
                _log.info("Packing {0} islands...".format(len(boxes)))
                best = _int_maxsize
                rounds = 15  # TODO
                while rounds > 0:
                        rounds -= 1
                        # Т.к. box_pack_2d псевдослучайный и может давать несколько результатов,
                        # то итеративно отбираем лучшие
                        _shuffle(boxes)
                        pack_x, pack_y = _box_pack_2d(boxes)
                        score = max(pack_x, pack_y)
                        _log.info("Packing round: {0}, score: {1}...".format(rounds, score))
                        if score >= best:
                                continue
                        for box in boxes:
                                box[4].packed_norm = _Vector(tuple(box[i] / score for i in range(4)))
                        best = score
                if best == _int_maxsize:
                        raise AssertionError()
        
        def _prepare_bake_obj(self):
                _commons.ensure_deselect_all_objects()
                mesh = _D.meshes.new("__Kawa_Bake_UV_Mesh")  # type: Mesh
                # Создаем столько полигонов, сколько трансформов
                bm = _bmesh_new()
                try:
                        for transforms in self._transforms.values():
                                for _ in range(len(transforms)):
                                        v0, v1, v2, v3 = bm.verts.new(), bm.verts.new(), bm.verts.new(), bm.verts.new()
                                        bm.faces.new((v0, v1, v2, v3))
                                bm.to_mesh(mesh)
                finally:
                        bm.free()
                # Создаем слои для преобразований
                mesh.uv_layers.new(name=self._UV_ORIGINAL)
                mesh.uv_layers.new(name=self._UV_ATLAS)
                mesh.materials.clear()
                for mat in self._transforms.keys():
                        mesh.materials.append(mat)
                mat2idx = {m: i for i, m in enumerate(mesh.materials)}
                # Прописываем в полигоны координаты и материалы
                uvl_original = mesh.uv_layers[self._UV_ORIGINAL]  # type: MeshUVLoopLayer
                uvl_atlas = mesh.uv_layers[self._UV_ATLAS]  # type: MeshUVLoopLayer
                uvd_original, uvd_atlas = uvl_original.data, uvl_atlas.data
                poly_idx = 0
                for mat, transforms in self._transforms.items():
                        for t in transforms:
                                poly = mesh.polygons[poly_idx]
                                if len(poly.loop_indices) != 4:
                                        raise AssertionError("len(poly.loop_indices) != 4", mesh, poly_idx, poly, len(poly.loop_indices))
                                if len(poly.vertices) != 4:
                                        raise AssertionError("len(poly.vertices) != 4", mesh, poly_idx, poly, len(poly.vertices))
                                for vert_idx, uv_a, uv_b in t.iterate_corners():
                                        mesh.vertices[poly.vertices[vert_idx]].co.xy = uv_b
                                        mesh.vertices[poly.vertices[vert_idx]].co.z = poly_idx * 1.0 / len(mesh.polygons)
                                        uvd_original[poly.loop_indices[vert_idx]].uv = uv_a
                                        uvd_atlas[poly.loop_indices[vert_idx]].uv = uv_b
                                poly.material_index = mat2idx[mat]
                                poly_idx += 1
                
                # Вставляем меш на сцену и активируем
                _commons.ensure_deselect_all_objects()
                for obj in _C.scene.objects:
                        obj.hide_render = True
                        obj.hide_set(True)
                self._bake_obj = _D.objects.new("__Kawa_Bake_UV_Object", mesh)  # add a new object using the mesh
                _C.scene.collection.objects.link(self._bake_obj)
                # Debug purposes
                for area in _C.screen.areas:
                        if area.type == 'VIEW_3D':
                                for region in area.regions:
                                        if region.type == 'WINDOW':
                                                override = {'area': area, 'region': region}
                                                _bpy.ops.view3d.view_axis(override, type='TOP', align_active=True)
                                                _bpy.ops.view3d.view_selected(override, use_all_regions=False)
                self._bake_obj.hide_render = False
                self._bake_obj.show_wire = True
                self._bake_obj.show_in_front = True
                #
                _commons.activate_object(self._bake_obj)
        
        def _call_before_bake_safe(self, bake_type: str, target_image: 'Image'):
                try:
                        self.before_bake(bake_type, target_image)
                except Exception as exc:
                        msg = 'cb_before_bake failed! {0} {1}'.format(bake_type, target_image)
                        _log.error(msg)
                        raise RuntimeError(msg, bake_type, target_image) from exc
        
        def _call_after_bake_safe(self, bake_type: str, target_image: 'Image'):
                try:
                        self.after_bake(bake_type, target_image)
                except Exception as exc:
                        msg = 'cb_after_bake failed! {0} {1}'.format(bake_type, target_image)
                        _log.error(msg)
                        raise RuntimeError(msg, bake_type, target_image) from exc
        
        def _check_material(self, mat: 'Material'):
                node_tree, out, surface, src_shader_s, src_shader = None, None, None, None, None
                try:
                        node_tree = mat.node_tree
                        nodes = node_tree.nodes
                        # groups = list(n for n in nodes if n.type == 'GROUP')
                        # if len(groups) > 0:
                        #       # Нужно убедиться, что node editor доступен.
                        #       self._get_node_editor_override()
                        out = _snodes.get_material_output(mat)
                        surface = out.inputs['Surface']  # type: NodeSocket
                        src_shader_link = surface.links[0] if len(surface.links) == 1 else None  # type: NodeLink
                        src_shader = src_shader_link.from_node  # type: ShaderNode
                        if src_shader is None:
                                raise RuntimeError('no shader found')
                except Exception as exc:
                        msg = "Material {0} is invalid!".format(mat.name)
                        _log.info(msg)
                        raise RuntimeError(msg, mat, node_tree, out, surface, src_shader_s, src_shader) from exc
        
        def _get_node_editor_override(self):
                if self._node_editor_override is not False:
                        return self._node_editor_override
                self._node_editor_override = None
                for screen in _D.screens:
                        for area_idx in range(len(screen.areas)):
                                area = screen.areas[area_idx]
                                if area.type == 'NODE_EDITOR':
                                        for region_idx in range(len(area.regions)):
                                                region = area.regions[region_idx]
                                                if region.type == 'WINDOW':
                                                        self._node_editor_override = {'screen': screen, 'area': area, 'region': region}
                                                        _log.info('Using NODE_EDITOR: screen={0} area=#{1} region=#{2}'.format(screen.name, area_idx, region_idx))
                                                        return self._node_editor_override  # break
                if self._node_editor_override is None:
                        raise RuntimeError('Can not find NODE_EDITOR')
        
        def _ungroup_nodes_for_bake(self, mat: 'Material'):
                override = None
                count = 0
                try:
                        pass
                # TODO unpacking just doesnt work
                #
                # if not any(True for n in mat.node_tree.nodes if n.type == 'GROUP'):
                #       return
                # log.info("group_ungroup begin: %s", mat.name)
                # ignore = set()
                # override = self._get_node_editor_override()
                # while True:
                #       log.info("group_ungroup repeat: %s - %d %d", mat.name, count, len(mat.node_tree.nodes))
                #       # Итеративное вскрытие групп
                #       mat.node_tree.nodes.active = None
                #
                #       groups = list()
                #       for nidx in range(len(mat.node_tree.nodes)):
                #               mat.node_tree.nodes[nidx].select = False
                #               if mat.node_tree.nodes[nidx].type == 'GROUP' and mat.node_tree.nodes[nidx] not in ignore:
                #                       # mat.node_tree.nodes[nidx].select = True
                #                       # mat.node_tree.nodes.active = mat.node_tree.nodes[nidx]
                #                       groups.append(mat.node_tree.nodes[nidx])
                #       if len(groups) == 0:
                #               log.info("no groups in %s", mat.name)
                #               break
                #
                #       from .node_ungroup import ungroup_nodes
                #       log.info("before danielenger's ungroup_nodes: %s - %s", mat.name, list(mat.node_tree.nodes))
                #       ungroup_nodes(mat, groups)
                #       log.info("after danielenger's ungroup_nodes: %s - %s", mat.name, list(mat.node_tree.nodes))
                #
                #       count += 1
                # log.info("group_ungroup end: %s - %s", mat.name, count)
                except Exception as exc:
                        raise RuntimeError('_ungroup_nodes_for_bake', mat, override, count, mat.node_tree.nodes.active) from exc
        
        def _edit_mat_for_bake(self, mat: 'Material', bake_type: 'str'):
                # Подключает alpha-emission шейдер на выход материала
                # Если не найден выход, DEFAULT или ALPHA, срёт ошибками
                # Здесь нет проверок на ошибки
                node_tree = mat.node_tree
                nodes = node_tree.nodes
                
                surface = _snodes.get_material_output(mat).inputs['Surface']  # type: NodeSocket
                src_shader_link = surface.links[0]  # type: NodeLink
                src_shader = src_shader_link.from_node  # type: ShaderNode
                
                def replace_shader():
                        # Замещает оригинальный шейдер на Emission
                        bake_shader = nodes.new('ShaderNodeEmission')  # type: ShaderNode
                        if src_shader_link is not None:
                                node_tree.links.remove(src_shader_link)
                        node_tree.links.new(bake_shader.outputs['Emission'], surface)
                        # log.info("Replacing shader %s -> %s on %s", src_shader, bake_shader, mat)
                        bake_color = bake_shader.inputs['Color']  # type: NodeSocketColor
                        return bake_shader, bake_color
                
                def copy_input(from_in_socket: 'NodeSocket', to_in_socket: 'NodeSocket'):
                        links = from_in_socket.links
                        if len(links) > 0:
                                link = links[0]  # type: NodeLink
                                node_tree.links.new(link.from_socket, to_in_socket)
                
                def copy_input_color(from_in_socket: 'NodeSocketColor', to_in_socket: 'NodeSocketColor'):
                        to_in_socket.default_value[:] = from_in_socket.default_value[:]
                        copy_input(from_in_socket, to_in_socket)
                
                def copy_input_value(from_in_socket: 'NodeSocketFloat', to_in_socket: 'NodeSocketColor'):
                        v = from_in_socket.default_value
                        to_in_socket.default_value[:] = (v, v, v, 1.0)
                        copy_input(from_in_socket, to_in_socket)
                
                if bake_type == 'ALPHA':
                        bake_shader, bake_color = replace_shader()
                        src_alpha = _snodes.get_node_input_safe(src_shader, 'Alpha')
                        if src_alpha is not None:  # TODO RGB <-> value
                                copy_input_value(src_alpha, bake_color)
                        else:
                                # По умолчанию непрозрачность
                                bake_color.default_value[:] = (1, 1, 1, 1.0)
                elif bake_type == 'DIFFUSE':
                        bake_shader, bake_color = replace_shader()
                        src_shader_color = src_shader.inputs.get('Base Color') or src_shader.inputs.get('Color')  # type: NodeSocket
                        if src_shader_color is not None:
                                copy_input_color(src_shader_color, bake_color)
                        else:
                                # По умолчанию 75% отражаемости
                                bake_color.default_value[:] = (0.75, 0.75, 0.75, 1.0)
                elif bake_type == 'METALLIC':
                        bake_shader, bake_color = replace_shader()
                        src_metallic = src_shader.inputs.get('Metallic') or src_shader.inputs.get('Specular')  # type: NodeSocket
                        if src_metallic is not None:  # TODO RGB <-> value
                                copy_input_value(src_metallic, bake_color)
                        else:
                                # По умолчанию 10% металличности
                                bake_color.default_value[:] = (0.1, 0.1, 0.1, 1.0)
                elif bake_type == 'ROUGHNESS':
                        bake_shader, bake_color = replace_shader()
                        src_roughness = src_shader.inputs.get('Roughness')  # type: NodeSocket
                        if src_roughness is not None:  # TODO RGB <-> value
                                copy_input_value(src_roughness, bake_color)
                        else:
                                # По умолчанию 90% шершавости
                                bake_color.default_value[:] = (0.9, 0.9, 0.9, 1.0)
        
        def _edit_mats_for_bake(self, bake_obj: 'Object', bake_type: 'str'):
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(bake_obj)
                for slot_idx in range(len(bake_obj.material_slots)):
                        bake_obj.active_material_index = slot_idx
                        mat = bake_obj.material_slots[slot_idx].material
                        try:
                                self._ungroup_nodes_for_bake(mat)
                                self._edit_mat_for_bake(mat, bake_type)
                        except Exception as exc:
                                msg = 'Error editing material {0} (#{1}) for {2} bake object {3}' \
                                        .format(mat, slot_idx, bake_type, bake_obj)
                                raise RuntimeError(msg, mat, slot_idx, bake_type, bake_obj) from exc
        
        def _try_edit_mats_for_bake(self, bake_obj: 'Object', bake_type: 'str'):
                try:
                        self._edit_mats_for_bake(bake_obj, bake_type)
                except Exception as exc:
                        raise RuntimeError("_edit_mats_for_bake", bake_obj, bake_type) from exc
        
        def _bake_image(self, bake_type: 'str', target_image: 'Image'):
                _log.info("Preparing for bake atlas Image={0} type={1} size={2}...".format(
                        repr(target_image.name), bake_type, tuple(target_image.size)))
                
                # Поскольку cycles - ссанина, нам проще сделать копию ._bake_obj
                # Сделать копии материалов на ._bake_obj
                # Кастомизировать материалы, вывести всё через EMIT
                
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(self._bake_obj)
                _commons.ensure_op_finished(_bpy.ops.object.duplicate(linked=False), name='bpy.ops.object.duplicate')
                local_bake_obj = _C.view_layer.objects.active
                self._bake_obj.hide_set(True)
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(local_bake_obj)
                _commons.ensure_op_finished(_bpy.ops.object.make_single_user(
                        object=True, obdata=True, material=True, animation=False,
                ), name='bpy.ops.object.make_single_user')
                
                if bake_type in ('ALPHA', 'DIFFUSE', 'METALLIC', 'ROUGHNESS'):
                        self._try_edit_mats_for_bake(local_bake_obj, bake_type)
                
                for slot in local_bake_obj.material_slots:  # type: MaterialSlot
                        n_bake = _snodes.prepare_and_get_node_for_baking(slot.material)
                        n_bake.image = target_image
                
                emit_types = ('EMIT', 'ALPHA', 'DIFFUSE', 'METALLIC', 'ROUGHNESS')
                cycles_bake_type = 'EMIT' if bake_type in emit_types else bake_type
                _C.scene.cycles.bake_type = cycles_bake_type
                _C.scene.render.bake.use_pass_direct = False
                _C.scene.render.bake.use_pass_indirect = False
                _C.scene.render.bake.use_pass_color = False
                _C.scene.render.bake.use_pass_emit = bake_type in emit_types
                _C.scene.render.bake.normal_space = 'TANGENT'
                _C.scene.render.bake.margin = 64
                _C.scene.render.bake.use_clear = True
                
                self._call_before_bake_safe(bake_type, target_image)
                
                _log.info("Trying to bake atlas Image={0} type={1}/{2} size={3}...".format(
                        repr(target_image.name), bake_type, cycles_bake_type, tuple(target_image.size)))
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(local_bake_obj)
                bake_start = _perf_counter()
                _commons.ensure_op_finished(_bpy.ops.object.bake(type=cycles_bake_type, use_clear=True), name='bpy.ops.object.bake')
                bake_time = _perf_counter() - bake_start
                _log.info("Baked atlas Image={0} type={1}, time spent: {2:.1f} sec.".format(repr(target_image.name), bake_type, bake_time))
                
                garbage_materials = set(slot.material for slot in local_bake_obj.material_slots)
                mesh = local_bake_obj.data
                _C.blend_data.objects.remove(local_bake_obj, do_unlink=True)
                _C.blend_data.meshes.remove(mesh, do_unlink=True)
                for mat in garbage_materials:
                        _C.blend_data.materials.remove(mat, do_unlink=True)
                
                self._call_after_bake_safe(bake_type, target_image)
        
        def _bake_images(self):
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(self._bake_obj)
                # Настраиваем UV слои под рендер
                for layer in _commons.get_mesh_safe(self._bake_obj).uv_layers:  # type: MeshUVLoopLayer
                        layer.active = layer.name == self._UV_ATLAS
                        layer.active_render = layer.name == self._UV_ORIGINAL
                        layer.active_clone = False
                for bake_type, target_image in self._bake_types:
                        self._bake_image(bake_type, target_image)
                        _gc_collect()
                
                _commons.ensure_deselect_all_objects()
                if self._bake_obj is not None:
                        # mesh = self._bake_obj.data
                        # bpy.context.blend_data.objects.remove(self._bake_obj, do_unlink=True)
                        # bpy.context.blend_data.meshes.remove(mesh, do_unlink=True)
                        pass
        
        def _get_target_material_safe(self, obj: 'Object', smat: 'Material'):
                tmat = None
                try:
                        tmat = self.get_target_material(obj, smat)
                # ...
                except Exception as exc:
                        msg = 'Can not get target material for {0} and {1}.'.format(obj, smat)
                        _log.error(msg)
                        raise RuntimeError(msg, smat, tmat) from exc
                return tmat
        
        def _apply_baked_materials(self):
                # TODO нужно как-то оптимизировать это говно
                # Для каждого материала смотрим каждый материала слот
                # Смотрим каждый полигон, если у него верный слот,
                # То берем средню UV и перебераем все transformы,
                # Нашли transform? запоминаем.
                # После обхода всех полигонов материала применяем transform на найденые индексы.
                
                _log.info("Applying UV...")
                mat_i, obj_i = 0, 0
                reporter = _LambdaReporter(self.report_time)
                reporter.func = lambda r, t: _log.info(
                        "Transforming UVs: Object={}/{}, Slots={}, Time={:.1f} sec, ETA={:.1f} sec...".format(
                                obj_i, len(self.objects), mat_i, t, r.get_eta(1.0 * obj_i / len(self.objects))))
                
                self._perf_find_transform = 0
                self._perf_apply_transform = 0
                self._perf_iter_polys = 0
                
                for obj in self.objects:
                        self._apply_baked_materials_on_obj(obj)
                        obj_i += 1
                        mat_i += len(obj.material_slots)
                        reporter.ask_report(False)
                        _commons.merge_same_material_slots(obj)
                reporter.ask_report(True)
                _log.info("Perf: find_transform: {0}, apply_transform: {1}, iter_polys: {2}".format(
                        self._perf_find_transform, self._perf_apply_transform, self._perf_iter_polys))
        
        def _apply_baked_materials_on_obj(self, obj: 'Object'):
                mesh = _commons.get_mesh_safe(obj)
                for material_index in range(len(mesh.materials)):
                        source_mat = mesh.materials[material_index]
                        transforms = self._transforms.get(source_mat)
                        if transforms is None:
                                continue  # Нет преобразований для данного материала
                        # Дегенеративная геометрия вызывает проблемы, по этому нужен epsilon.
                        # Зазор между боксами не менее epsilon материала, по этому возьмём половину.
                        epsilon = self._get_epsilon_safe(obj, source_mat)
                        src_size_x, src_size_y = self._matsizes[source_mat]
                        epsilon_x, epsilon_y = epsilon / src_size_x / 2, epsilon / src_size_y / 2
                        
                        maps = dict()  # type: Dict[int, UVTransform]
                        target_mat = self._materials.get((obj, source_mat))
                        uv_data = self._get_uv_data_safe(obj, source_mat, mesh)
                        
                        _t3 = _perf_counter()
                        for poly in mesh.polygons:
                                if poly.material_index != material_index:
                                        continue
                                mean_uv = _Vector((0, 0))
                                for loop in poly.loop_indices:
                                        mean_uv = mean_uv + uv_data[loop].uv
                                mean_uv /= len(poly.loop_indices)
                                transform = None
                                # Поиск трансформа для данного полигона
                                _t1 = _perf_counter()
                                for t in transforms:
                                        # Должно работать без эпсилонов
                                        if t.is_match(mean_uv, epsilon_x=epsilon_x, epsilon_y=epsilon_y):
                                                transform = t
                                                break
                                self._perf_find_transform += _perf_counter() - _t1
                                if transform is None:
                                        # Такая ситуация не должна случаться:
                                        # Если материал подлежал запеканию, то все участки должны были ранее покрыты трансформами.
                                        msg = 'No UV transform for Obj={0}, Mesh={1}, SMat={2}, Poly={3}, UV={4}, Transforms:' \
                                                .format(repr(obj.name), repr(mesh.name), repr(source_mat.name), repr(mean_uv), poly)
                                        _log.error(msg)
                                        for transform in transforms:
                                                _log.error('\t- {}'.format(repr(transform)))
                                        raise AssertionError(msg, obj, source_mat, poly, mean_uv, transforms)
                                for loop in poly.loop_indices:
                                        maps[loop] = transform
                        self._perf_iter_polys += _perf_counter() - _t3
                        # Применение трансформов.
                        _t2 = _perf_counter()
                        for loop, transform in maps.items():
                                # TODO по результатам замеров тут самая тормозная точка,
                                # надо подумать о том как это распараллелить или типа того.
                                vec2 = uv_data[loop].uv  # type: Vector
                                vec2 = transform.apply(vec2)
                                uv_data[loop].uv = vec2
                        self._perf_apply_transform += _perf_counter() - _t2
                        mesh.materials[material_index] = target_mat
                        obj.material_slots[material_index].material = target_mat
        
        def bake_atlas(self):
                """
                Run the baking process!
                """
                _log.info("Baking atlas!")
                self._prepare_objects()
                self._prepare_target_images()
                self._prepare_materials()
                self._prepare_matsizes()
                # Создайм вспомогательные дупликаты, они нужны только для
                # поиска UV островов
                self._make_duplicates()
                # Разбивка вспомогательные дупликатов по материалам
                self._separate_duplicates()
                # Может оказаться так, что не все материалы подлежат запеканию
                # Вспомогательные дупликаты с не нужными материалами удаляются
                self._cleanup_duplicates()
                # Оставщиеся вспомогательные дупликаты группируются по материалам
                self._group_duplicates()
                # Для каждого материала выполняем поиск островов
                self._find_islands()
                # После того, как острова найдены, вспомогательные дупликаты более не нужны, удаляем их
                self._delete_groups()
                # Острава нужно разместить на атласе.
                # Для этого используется mathutils.geometry.box_pack_2d
                # Для этого нужно сконвертировать
                self._create_transforms_from_islands()
                self._pack_islands()
                # Не трогаем исходники, создаем вспомогательный меш-объект для запекания
                self._prepare_bake_obj()
                self._bake_images()
                # После запекания к исходникам применяются новые материалы и преобразования
                self._apply_baked_materials()
                _log.info("Woohoo!")


__pdoc__ = dict()
for _n in dir(UVTransform):
        if hasattr(UVTransform, _n):
                __pdoc__[UVTransform.__name__ + '.' + _n] = False

Classes

class BaseAtlasBaker

Base class for Atlas Baking. You must extend this class with required and necessary methods for your case, configure variables and then run bake_atlas.

Expand source code
class BaseAtlasBaker:
        """
        Base class for Atlas Baking.
        You must extend this class with required and necessary methods for your case,
        configure variables and then run `bake_atlas`.
        """
        
        ISLAND_TYPES = ('POLYGON', 'OBJECT')
        """
        Available types of UV-Islands Searching for reference.
        Set per-Object and per-Material, see `get_island_mode`.
        
        - `'POLYGON'` will try to use every polygon provided to find rectangular UV areas of Material.
        Allows to detect separated UV parts from same material and put it separately and more efficient on atlas.
        Slower, but can result dense and efficient packing.
        
        - `'OBJECT'` will count each Material on each Object as single united island.
        Fast, but can pick large unused areas of Materials for atlasing and final atlas may be packed inefficient.
        """
        
        BAKE_TYPES = ('DIFFUSE', 'ALPHA', 'EMIT', 'NORMAL', 'ROUGHNESS', 'METALLIC')
        """
        Available types of baking layers for reference.
        Note, there is no DIFFUSE+ALPHA RGBA (use separate textures) and SMOOTHNESS (use ROUGHNESS instead) yet.
        """
        
        # Имена UV на ._bake_obj
        _UV_ORIGINAL = "UV-Original"
        _UV_ATLAS = "UV-Atlas"
        
        _PROC_ORIGINAL_UV_NAME = "__AtlasBaker_UV_Main_Original"
        _PROC_TARGET_ATLAS_UV_NAME = "__AtlasBaker_UV_Main_Target"
        _PROP_ORIGIN_OBJECT = "__AtlasBaker_OriginObject"
        _PROP_ORIGIN_MESH = "__AtlasBaker_OriginMesh"
        
        _PROC_NAME = "__AtlasBaker_Processing_"
        
        def __init__(self):
                self.objects = set()  # type: Set[Object]
                """ Mesh-Objects that will be atlassed. """
                
                self.target_size = (1, 1)  # type: Tuple[int, int]
                """
                Size of atlas. Actually used only as aspect ratio.
                Your target images (See `get_target_image`) must match this ratio.
                """
                
                self.padding = 4  # type: float
                """ Padding added to each UV Island around to avoid leaks. """
                
                self.report_time = 5
                """ Minimum time between progress reports into logfile when running long and heavy operations.  """
                
                # # # Внутренее # # #
                
                self._materials = dict()  # type: Dict[Tuple[Object, Material], Material]
                self._matsizes = dict()  # type: Dict[Material, Tuple[float, float]]
                self._bake_types = list()  # type: List[Tuple[str, Image]]
                # Объекты, скопированные для операций по поиску UV развёрток
                self._copies = set()  # type: Set[Object]
                # Группы объектов по материалам из ._copies
                self._groups = dict()  # type: Dict[Material, Set[Object]]
                # Острова UV найденые на материалах из ._groups
                self._islands = dict()  # type: Dict[Material, _uv.IslandsBuilder]
                # Преобразования, необходимые для получения нового UV для атласса
                self._transforms = dict()  # type: Dict[Material, List[UVTransform]]
                # Вспомогательный объект, необходимый для запекания атласа
                self._bake_obj = None  # type: Optional[Object]
                self._node_editor_override = False
        
        # # # Переопределяемые методы # # #
        
        def get_material_size(self, src_mat: 'Material') -> 'Optional[Tuple[float, float]]':
                """
                Must return size of material.
                This is relative compared with other Materials to figure out final area of Material on atlas.
                The real size of material will be different anyways.
                
                You can use `kawa_scripts.tex_size_finder.TexSizeFinder` here.
                """
                raise NotImplementedError('get_material_size')
        
        def get_target_material(self, origin: 'Object', src_mat: 'Material') -> 'Material':
                """
                Must return target Material for source Material.
                Ths source Material on this Object will be replaced with target Material after baking.
                Atlas Baker does not create final Materials by it's own.
                You should prepare target materials (with target images) and provide it here, so Atlas Baker can use and assign it.
                """
                raise NotImplementedError('get_target_material')
        
        def get_target_image(self, bake_type: str) -> 'Optional[Image]':
                """
                Must return target Image for given bake type.
                Atlas Baker will bake atlas onto this Image.
                If Image is not provided (None or False) this bake type will not be baked.
                See `BAKE_TYPES` for available bake types.
                """
                raise NotImplementedError('get_target_image')
        
        def get_uv_name(self, obj: 'Object', mat: 'Material') -> 'Opional[str]':
                """
                Should return the name of UV Layer of given Object and Material that will be used for baking.
                If not implemented (or returns None or False) first UV layer will be used.
                This layer will be edited to match Atlas and target Material.
                """
                return  # TODO
        
        def get_island_mode(self, origin: 'Object', mat: 'Material') -> 'str':
                """
                Must return one of island search types. See `ISLAND_TYPES` for details.
                """
                return 'POLYGON'
        
        def get_epsilon(self, obj: 'Object', mat: 'Material') -> 'Optional[float]':
                """
                Should return precision value in pixel-space for given Object and Material.
                Note, size obtained from `get_material_size` is used for pixel-space.
                """
                return None
        
        def before_bake(self, bake_type: str, target_image: 'Image'):
                """
                This method is called before baking given type and Image.
                Note, Image obtained from 'get_target_image' is used for `target_image`.
                You can prepare something here, for example, adjust Blender's baking settings.
                """
                pass
        
        def after_bake(self, bake_type: str, target_image: 'Image'):
                """
                This method is called after baking given type and Image.
                Note, Image obtained from 'get_target_image' is used for `target_image`.
                You can post-process something here, for example, save baked Image.
                """
                pass
        
        def _get_source_object(self, copy_obj: 'Object'):
                name = copy_obj.get(self._PROP_ORIGIN_OBJECT)
                origin_obj = _D.objects.get(name)
                return origin_obj
        
        def _get_matsize_safe(self, mat: 'Material') -> 'Tuple[float, float]':
                default_size = (16, 16)
                size = None
                try:
                        # TODO если размер текстуры не выявлен, нужно отрабатывать чётче
                        size = self.get_material_size(mat)
                        if not size:
                                size = default_size
                        if not isinstance(size, tuple) or len(size) != 2 or not isinstance(size[0], (int, float)) or not isinstance(size[1], (int, float)):
                                _log.warning("Material {0} have invalid material size: {1}".format(mat, repr(size)))
                                size = default_size
                        if size[0] <= 0 or size[1] <= 0:
                                _log.warning("Material {0} have invalid material size: {1}".format(mat, repr(size)))
                                size = default_size
                        return size
                except Exception as exc:
                        msg = 'Can not get size of material {0}.'.format(mat)
                        _log.error(msg)
                        raise RuntimeError(msg, mat, size) from exc
        
        def _get_epsilon_safe(self, obj: 'Object', mat: 'Material'):
                epsilon = None
                try:
                        epsilon = self.get_epsilon(obj, mat) or 1
                        return epsilon
                except Exception as exc:
                        msg = 'Can not get epsilon for {0} and {1}.'.format(obj, mat)
                        _log.error(msg)
                        raise RuntimeError(msg, obj, mat, epsilon) from exc
        
        def _get_uv_data_safe(self, obj: 'Object', mat: 'Material', mesh: 'Mesh'):
                uv_name = None
                try:
                        uv_name = self.get_uv_name(obj, mat) or 0
                        uv_data = mesh.uv_layers[uv_name].data  # type: List[MeshUVLoop]
                except Exception as exc:
                        msg = 'Can not get uv_layers[{2}] data for {0} and {1}.'.format(obj, mat, uv_name)
                        _log.error(msg)
                        raise RuntimeError(msg, obj, mat, mesh, uv_name) from exc
                return uv_data
        
        def _get_bake_image_safe(self, bake_type: str):
                image = None
                try:
                        image = self.get_target_image(bake_type)
                        # ...
                        return image
                except Exception as exc:
                        msg = 'Can not get image for bake type {0}.'.format(bake_type)
                        _log.error(msg)
                        raise RuntimeError(msg, bake_type, image) from exc
        
        def _prepare_objects(self):
                objects = set()
                for obj in self.objects:
                        if not isinstance(obj.data, _bpy.types.Mesh):
                                _log.warning("{0} is not a valid mesh-object!".format(obj))
                                continue
                        objects.add(obj)
                self.objects = objects
        
        def _prepare_target_images(self):
                for bake_type in self.BAKE_TYPES:
                        target_image = self._get_bake_image_safe(bake_type)
                        if target_image is None or target_image is False:
                                continue
                        self._bake_types.append((bake_type, target_image))
        
        def _prepare_materials(self):
                for obj in self.objects:
                        for slot in obj.material_slots:  # type: MaterialSlot
                                if slot is None or slot.material is None:
                                        _log.warning("Empty material slot detected: {0}".format(obj))
                                        continue
                                tmat = self._get_target_material_safe(obj, slot.material)
                                if isinstance(tmat, _bpy.types.Material):
                                        self._materials[(obj, slot.material)] = tmat
                mats = set(x[1] for x in self._materials.keys())
                _log.info("Validating {0} source materials...".format(len(mats)))
                for mat in mats:
                        self._check_material(mat)
                _log.info("Validated {0} source materials.".format(len(mats)))
        
        def _prepare_matsizes(self):
                mat_i = 0
                smats = set(x[1] for x in self._materials.keys())
                
                reporter = _LambdaReporter(self.report_time)
                reporter.func = lambda r, t: _log.info(
                        "Preparing material sizes, Materials={0}/{1}, Time={2:.1f} sec, ETA={3:.1f} sec...".format(
                                mat_i, len(smats), t, r.get_eta(1.0 * mat_i / len(smats))))
                
                for smat in smats:
                        self._matsizes[smat] = self._get_matsize_safe(smat)
                        mat_i += 1
                        reporter.ask_report(False)
                reporter.ask_report(True)
        
        def _make_duplicates(self):
                # Делает дубликаты объектов, сохраняет в ._copies
                _log.info("Duplicating temp objects for atlasing...")
                _commons.ensure_deselect_all_objects()
                for obj in self.objects:
                        if isinstance(obj.data, _bpy.types.Mesh):
                                _commons.activate_object(obj)
                        obj[self._PROP_ORIGIN_OBJECT] = obj.name
                        obj.data[self._PROP_ORIGIN_MESH] = obj.data.name
                _commons.ensure_op_finished(_bpy.ops.object.duplicate(linked=False), name='bpy.ops.object.duplicate')
                self._copies.update(_C.selected_objects)
                # Меченые имена, что бы если скрипт крашнется сразу было их видно
                for obj in self._copies:
                        obj_name = obj.get(self._PROP_ORIGIN_OBJECT) or 'None'
                        mesh_name = obj.data.get(self._PROP_ORIGIN_MESH) or 'None'
                        obj.name = self._PROC_NAME + obj_name
                        obj.data.name = self._PROC_NAME + mesh_name
                _log.info("Duplicated {0} temp objects for atlasing.".format(len(self._copies)))
                _commons.ensure_deselect_all_objects()
        
        def _separate_duplicates(self):
                # Разбивает дупликаты по материалам
                _log.info("Separating temp objects for atlasing...")
                _commons.ensure_deselect_all_objects()
                _commons.activate_objects(self._copies)
                _commons.ensure_op_finished(_bpy.ops.mesh.separate(type='MATERIAL'), name='bpy.ops.mesh.separate')
                count = len(self._copies)
                self._copies.update(_C.selected_objects)
                _commons.ensure_deselect_all_objects()
                _log.info("Separated {0} -> {1} temp objects".format(count, len(self._copies)))
        
        def _get_single_material(self, obj: 'Object') -> 'Material':
                ms_c = len(obj.material_slots)
                if ms_c != 1:  # TODO
                        raise RuntimeError("ms_c != 1", obj)
                slot = obj.material_slots[0]  # type: MaterialSlot
                mat = slot.material if slot is not None else None
                if mat is None:  # TODO
                        raise RuntimeError("mat is None", obj)
                return mat
        
        def _cleanup_duplicates(self):
                # Удаляет те материалы, которые не будут атлассироваться
                source_materials = list(x[1] for x in self._materials.keys())
                to_delete = set()
                for cobj in self._copies:
                        sobj = self._get_source_object(cobj)
                        smat = self._get_single_material(cobj)
                        tmat = self._materials.get((sobj, smat))
                        if tmat is None or tmat is False:
                                to_delete.add(cobj)
                _commons.ensure_deselect_all_objects()
                for cobj in to_delete:
                        cobj.hide_set(False)
                        cobj.select_set(True)
                _commons.ensure_op_finished(_bpy.ops.object.delete(use_global=True, confirm=True), name='bpy.ops.object.delete')
                if len(_C.selected_objects) > 0:  # TODO
                        raise RuntimeError("len(bpy.context.selected_objects) > 0", list(_C.selected_objects))
                for cobj in to_delete:
                        self._copies.discard(cobj)
                _log.info("Removed {0} temp objects, left {1} objects.".format(len(to_delete), len(self._copies)))
        
        def _group_duplicates(self):
                # Группирует self._copies по материалам в self._groups
                for obj in self._copies:
                        mat = self._get_single_material(obj)
                        group = self._groups.get(mat)
                        if group is None:
                                group = set()
                                self._groups[mat] = group
                        group.add(obj)
                _log.info("Grouped {0} temp objects into %d material groups.".format(len(self._copies), len(self._groups)))
        
        def _find_islands(self):
                mat_i, obj_i = 0, 0
                
                def do_report(r, t):
                        islands = sum(len(x.bboxes) for x in self._islands.values())
                        merges = sum(x.merges for x in self._islands.values())
                        _log.info("Searching UV islands: Objects={0}/{1}, Materials={2}/{3}, Islands={4}, Merges={5}, Time={6:.1f} sec, ETA={7:.1f} sec..."
                                .format(obj_i, len(self._copies), mat_i, len(self._groups), islands, merges, t, r.get_eta(1.0 * obj_i / len(self._copies))))
                        
                reporter = _LambdaReporter(self.report_time)
                reporter.func = do_report
                
                _log.info("Searching islands...")
                # Поиск островов, наполнение self._islands
                for mat, group in self._groups.items():
                        # log.info("Searching islands of material %s in %d objects...", mat.name, len(group))
                        mat_size_x, mat_size_y = self._matsizes.get(mat)
                        builder = _commons.dict_get_or_add(self._islands, mat, _uv.IslandsBuilder)
                        for obj in group:
                                origin = self._get_source_object(obj)
                                mesh = _commons.get_mesh_safe(obj)
                                epsilon = self._get_epsilon_safe(origin, mat)
                                uv_data = self._get_uv_data_safe(origin, mat, mesh)
                                
                                polygons = list(mesh.polygons)  # type: List[MeshPolygon]
                                # Оптимизация. Сортировка от большей площади к меньшей,
                                # что бы сразу сделать большие боксы и реже пере-расширять их.
                                polygons.sort(key=lambda p: _uv.uv_area(p, uv_data), reverse=True)
                                
                                mode = self.get_island_mode(origin, mat)
                                if mode == 'OBJECT':
                                        # Режим одного острова: все точки всех полигонов формируют общий bbox
                                        vec2s = list()
                                        for poly in polygons:
                                                for loop in poly.loop_indices:
                                                        vec2 = uv_data[loop].uv.xy  # type: Vector
                                                        # Преобразование в размеры текстуры
                                                        vec2.x *= mat_size_x
                                                        vec2.y *= mat_size_y
                                                        vec2s.append(vec2)
                                        builder.add_seq(vec2s, epsilon=epsilon)
                                elif mode == 'POLYGON':
                                        # Режим многих островов: каждый полигон формируют свой bbox
                                        try:
                                                for poly in polygons:
                                                        vec2s = list()
                                                        for loop in poly.loop_indices:  # type: int
                                                                vec2 = uv_data[loop].uv.xy  # type: Vector
                                                                # Преобразование в размеры текстуры
                                                                vec2.x *= mat_size_x
                                                                vec2.y *= mat_size_y
                                                                vec2s.append(vec2)
                                                        builder.add_seq(vec2s, epsilon=epsilon)
                                        except Exception as exc:
                                                raise RuntimeError("Error searching multiple islands!", uv_data, obj, mat, mesh, builder) from exc
                                else:
                                        raise RuntimeError('Invalid mode', mode)
                                obj_i += 1
                                reporter.ask_report(False)
                        mat_i += 1
                        reporter.ask_report(False)
                reporter.ask_report(True)
                
                # for mat, builder in self._islands.items():
                #       log.info("\tMaterial %s have %d islands:", mat, len(builder.bboxes))
                #       for bbox in builder.bboxes:
                #               log.info("\t\t%s", str(bbox))
                # В процессе работы с остравами мы могли настрать много мусора,
                # можно явно от него избавиться
                _gc_collect()
                pass
        
        def _delete_groups(self):
                count = len(self._copies)
                _log.info("Removing {0} temp objects...".format(count))
                _commons.ensure_deselect_all_objects()
                for obj in self._copies:
                        obj.hide_set(False)
                        obj.select_set(True)
                _commons.ensure_op_finished(_bpy.ops.object.delete(use_global=True, confirm=True), name='bpy.ops.object.delete')
                if len(_C.selected_objects) > 0:  # TODO
                        raise RuntimeError("len(bpy.context.selected_objects) > 0", list(_C.selected_objects))
                _log.info("Removed {0} temp objects.".format(count))
        
        def _create_transforms_from_islands(self):
                # Преобразует острава в боксы в формате mathutils.geometry.box_pack_2d
                for mat, builder in self._islands.items():
                        for bbox in builder.bboxes:
                                if not bbox.is_valid():
                                        raise ValueError("box is invalid: ", bbox, mat, builder, builder.bboxes)
                                origin_w, origin_h = self._matsizes[mat]
                                t = UVTransform()
                                t.material = mat
                                # две точки -> одна точка + размер
                                x, w = bbox.mn.x, (bbox.mx.x - bbox.mn.x)
                                y, h = bbox.mn.y, (bbox.mx.y - bbox.mn.y)
                                # t.origin_tex = (x, y, w, h)
                                t.origin_norm = _Vector((x / origin_w, y / origin_h, w / origin_w, h / origin_h))
                                # добавляем отступы
                                xp, yp = x - self.padding, y - self.padding,
                                wp, hp = w + 2 * self.padding, h + 2 * self.padding
                                # meta.padded_tex = (xp, yp, wp, hp)
                                t.padded_norm = _Vector((xp / origin_w, yp / origin_h, wp / origin_w, hp / origin_h))
                                # Координаты для упаковки
                                # Т.к. box_pack_2d пытается запаковать в квадрат, а у нас может быть текстура любой формы,
                                # то необходимо скорректировать пропорции
                                xb, yb = xp / self.target_size[0], yp / self.target_size[1]
                                wb, hb = wp / self.target_size[0], hp / self.target_size[1]
                                t.packed_norm = _Vector((xb, yb, wb, hb))
                                metas = _commons.dict_get_or_add(self._transforms, mat, list)
                                metas.append(t)
        
        def _pack_islands(self):
                # Несколько итераций перепаковки
                # TODO вернуть систему с раундами
                boxes = list()  # type: List[List[Union[float, UVTransform]]]
                for metas in self._transforms.values():
                        for meta in metas:
                                boxes.append([*meta.packed_norm, meta])
                _log.info("Packing {0} islands...".format(len(boxes)))
                best = _int_maxsize
                rounds = 15  # TODO
                while rounds > 0:
                        rounds -= 1
                        # Т.к. box_pack_2d псевдослучайный и может давать несколько результатов,
                        # то итеративно отбираем лучшие
                        _shuffle(boxes)
                        pack_x, pack_y = _box_pack_2d(boxes)
                        score = max(pack_x, pack_y)
                        _log.info("Packing round: {0}, score: {1}...".format(rounds, score))
                        if score >= best:
                                continue
                        for box in boxes:
                                box[4].packed_norm = _Vector(tuple(box[i] / score for i in range(4)))
                        best = score
                if best == _int_maxsize:
                        raise AssertionError()
        
        def _prepare_bake_obj(self):
                _commons.ensure_deselect_all_objects()
                mesh = _D.meshes.new("__Kawa_Bake_UV_Mesh")  # type: Mesh
                # Создаем столько полигонов, сколько трансформов
                bm = _bmesh_new()
                try:
                        for transforms in self._transforms.values():
                                for _ in range(len(transforms)):
                                        v0, v1, v2, v3 = bm.verts.new(), bm.verts.new(), bm.verts.new(), bm.verts.new()
                                        bm.faces.new((v0, v1, v2, v3))
                                bm.to_mesh(mesh)
                finally:
                        bm.free()
                # Создаем слои для преобразований
                mesh.uv_layers.new(name=self._UV_ORIGINAL)
                mesh.uv_layers.new(name=self._UV_ATLAS)
                mesh.materials.clear()
                for mat in self._transforms.keys():
                        mesh.materials.append(mat)
                mat2idx = {m: i for i, m in enumerate(mesh.materials)}
                # Прописываем в полигоны координаты и материалы
                uvl_original = mesh.uv_layers[self._UV_ORIGINAL]  # type: MeshUVLoopLayer
                uvl_atlas = mesh.uv_layers[self._UV_ATLAS]  # type: MeshUVLoopLayer
                uvd_original, uvd_atlas = uvl_original.data, uvl_atlas.data
                poly_idx = 0
                for mat, transforms in self._transforms.items():
                        for t in transforms:
                                poly = mesh.polygons[poly_idx]
                                if len(poly.loop_indices) != 4:
                                        raise AssertionError("len(poly.loop_indices) != 4", mesh, poly_idx, poly, len(poly.loop_indices))
                                if len(poly.vertices) != 4:
                                        raise AssertionError("len(poly.vertices) != 4", mesh, poly_idx, poly, len(poly.vertices))
                                for vert_idx, uv_a, uv_b in t.iterate_corners():
                                        mesh.vertices[poly.vertices[vert_idx]].co.xy = uv_b
                                        mesh.vertices[poly.vertices[vert_idx]].co.z = poly_idx * 1.0 / len(mesh.polygons)
                                        uvd_original[poly.loop_indices[vert_idx]].uv = uv_a
                                        uvd_atlas[poly.loop_indices[vert_idx]].uv = uv_b
                                poly.material_index = mat2idx[mat]
                                poly_idx += 1
                
                # Вставляем меш на сцену и активируем
                _commons.ensure_deselect_all_objects()
                for obj in _C.scene.objects:
                        obj.hide_render = True
                        obj.hide_set(True)
                self._bake_obj = _D.objects.new("__Kawa_Bake_UV_Object", mesh)  # add a new object using the mesh
                _C.scene.collection.objects.link(self._bake_obj)
                # Debug purposes
                for area in _C.screen.areas:
                        if area.type == 'VIEW_3D':
                                for region in area.regions:
                                        if region.type == 'WINDOW':
                                                override = {'area': area, 'region': region}
                                                _bpy.ops.view3d.view_axis(override, type='TOP', align_active=True)
                                                _bpy.ops.view3d.view_selected(override, use_all_regions=False)
                self._bake_obj.hide_render = False
                self._bake_obj.show_wire = True
                self._bake_obj.show_in_front = True
                #
                _commons.activate_object(self._bake_obj)
        
        def _call_before_bake_safe(self, bake_type: str, target_image: 'Image'):
                try:
                        self.before_bake(bake_type, target_image)
                except Exception as exc:
                        msg = 'cb_before_bake failed! {0} {1}'.format(bake_type, target_image)
                        _log.error(msg)
                        raise RuntimeError(msg, bake_type, target_image) from exc
        
        def _call_after_bake_safe(self, bake_type: str, target_image: 'Image'):
                try:
                        self.after_bake(bake_type, target_image)
                except Exception as exc:
                        msg = 'cb_after_bake failed! {0} {1}'.format(bake_type, target_image)
                        _log.error(msg)
                        raise RuntimeError(msg, bake_type, target_image) from exc
        
        def _check_material(self, mat: 'Material'):
                node_tree, out, surface, src_shader_s, src_shader = None, None, None, None, None
                try:
                        node_tree = mat.node_tree
                        nodes = node_tree.nodes
                        # groups = list(n for n in nodes if n.type == 'GROUP')
                        # if len(groups) > 0:
                        #       # Нужно убедиться, что node editor доступен.
                        #       self._get_node_editor_override()
                        out = _snodes.get_material_output(mat)
                        surface = out.inputs['Surface']  # type: NodeSocket
                        src_shader_link = surface.links[0] if len(surface.links) == 1 else None  # type: NodeLink
                        src_shader = src_shader_link.from_node  # type: ShaderNode
                        if src_shader is None:
                                raise RuntimeError('no shader found')
                except Exception as exc:
                        msg = "Material {0} is invalid!".format(mat.name)
                        _log.info(msg)
                        raise RuntimeError(msg, mat, node_tree, out, surface, src_shader_s, src_shader) from exc
        
        def _get_node_editor_override(self):
                if self._node_editor_override is not False:
                        return self._node_editor_override
                self._node_editor_override = None
                for screen in _D.screens:
                        for area_idx in range(len(screen.areas)):
                                area = screen.areas[area_idx]
                                if area.type == 'NODE_EDITOR':
                                        for region_idx in range(len(area.regions)):
                                                region = area.regions[region_idx]
                                                if region.type == 'WINDOW':
                                                        self._node_editor_override = {'screen': screen, 'area': area, 'region': region}
                                                        _log.info('Using NODE_EDITOR: screen={0} area=#{1} region=#{2}'.format(screen.name, area_idx, region_idx))
                                                        return self._node_editor_override  # break
                if self._node_editor_override is None:
                        raise RuntimeError('Can not find NODE_EDITOR')
        
        def _ungroup_nodes_for_bake(self, mat: 'Material'):
                override = None
                count = 0
                try:
                        pass
                # TODO unpacking just doesnt work
                #
                # if not any(True for n in mat.node_tree.nodes if n.type == 'GROUP'):
                #       return
                # log.info("group_ungroup begin: %s", mat.name)
                # ignore = set()
                # override = self._get_node_editor_override()
                # while True:
                #       log.info("group_ungroup repeat: %s - %d %d", mat.name, count, len(mat.node_tree.nodes))
                #       # Итеративное вскрытие групп
                #       mat.node_tree.nodes.active = None
                #
                #       groups = list()
                #       for nidx in range(len(mat.node_tree.nodes)):
                #               mat.node_tree.nodes[nidx].select = False
                #               if mat.node_tree.nodes[nidx].type == 'GROUP' and mat.node_tree.nodes[nidx] not in ignore:
                #                       # mat.node_tree.nodes[nidx].select = True
                #                       # mat.node_tree.nodes.active = mat.node_tree.nodes[nidx]
                #                       groups.append(mat.node_tree.nodes[nidx])
                #       if len(groups) == 0:
                #               log.info("no groups in %s", mat.name)
                #               break
                #
                #       from .node_ungroup import ungroup_nodes
                #       log.info("before danielenger's ungroup_nodes: %s - %s", mat.name, list(mat.node_tree.nodes))
                #       ungroup_nodes(mat, groups)
                #       log.info("after danielenger's ungroup_nodes: %s - %s", mat.name, list(mat.node_tree.nodes))
                #
                #       count += 1
                # log.info("group_ungroup end: %s - %s", mat.name, count)
                except Exception as exc:
                        raise RuntimeError('_ungroup_nodes_for_bake', mat, override, count, mat.node_tree.nodes.active) from exc
        
        def _edit_mat_for_bake(self, mat: 'Material', bake_type: 'str'):
                # Подключает alpha-emission шейдер на выход материала
                # Если не найден выход, DEFAULT или ALPHA, срёт ошибками
                # Здесь нет проверок на ошибки
                node_tree = mat.node_tree
                nodes = node_tree.nodes
                
                surface = _snodes.get_material_output(mat).inputs['Surface']  # type: NodeSocket
                src_shader_link = surface.links[0]  # type: NodeLink
                src_shader = src_shader_link.from_node  # type: ShaderNode
                
                def replace_shader():
                        # Замещает оригинальный шейдер на Emission
                        bake_shader = nodes.new('ShaderNodeEmission')  # type: ShaderNode
                        if src_shader_link is not None:
                                node_tree.links.remove(src_shader_link)
                        node_tree.links.new(bake_shader.outputs['Emission'], surface)
                        # log.info("Replacing shader %s -> %s on %s", src_shader, bake_shader, mat)
                        bake_color = bake_shader.inputs['Color']  # type: NodeSocketColor
                        return bake_shader, bake_color
                
                def copy_input(from_in_socket: 'NodeSocket', to_in_socket: 'NodeSocket'):
                        links = from_in_socket.links
                        if len(links) > 0:
                                link = links[0]  # type: NodeLink
                                node_tree.links.new(link.from_socket, to_in_socket)
                
                def copy_input_color(from_in_socket: 'NodeSocketColor', to_in_socket: 'NodeSocketColor'):
                        to_in_socket.default_value[:] = from_in_socket.default_value[:]
                        copy_input(from_in_socket, to_in_socket)
                
                def copy_input_value(from_in_socket: 'NodeSocketFloat', to_in_socket: 'NodeSocketColor'):
                        v = from_in_socket.default_value
                        to_in_socket.default_value[:] = (v, v, v, 1.0)
                        copy_input(from_in_socket, to_in_socket)
                
                if bake_type == 'ALPHA':
                        bake_shader, bake_color = replace_shader()
                        src_alpha = _snodes.get_node_input_safe(src_shader, 'Alpha')
                        if src_alpha is not None:  # TODO RGB <-> value
                                copy_input_value(src_alpha, bake_color)
                        else:
                                # По умолчанию непрозрачность
                                bake_color.default_value[:] = (1, 1, 1, 1.0)
                elif bake_type == 'DIFFUSE':
                        bake_shader, bake_color = replace_shader()
                        src_shader_color = src_shader.inputs.get('Base Color') or src_shader.inputs.get('Color')  # type: NodeSocket
                        if src_shader_color is not None:
                                copy_input_color(src_shader_color, bake_color)
                        else:
                                # По умолчанию 75% отражаемости
                                bake_color.default_value[:] = (0.75, 0.75, 0.75, 1.0)
                elif bake_type == 'METALLIC':
                        bake_shader, bake_color = replace_shader()
                        src_metallic = src_shader.inputs.get('Metallic') or src_shader.inputs.get('Specular')  # type: NodeSocket
                        if src_metallic is not None:  # TODO RGB <-> value
                                copy_input_value(src_metallic, bake_color)
                        else:
                                # По умолчанию 10% металличности
                                bake_color.default_value[:] = (0.1, 0.1, 0.1, 1.0)
                elif bake_type == 'ROUGHNESS':
                        bake_shader, bake_color = replace_shader()
                        src_roughness = src_shader.inputs.get('Roughness')  # type: NodeSocket
                        if src_roughness is not None:  # TODO RGB <-> value
                                copy_input_value(src_roughness, bake_color)
                        else:
                                # По умолчанию 90% шершавости
                                bake_color.default_value[:] = (0.9, 0.9, 0.9, 1.0)
        
        def _edit_mats_for_bake(self, bake_obj: 'Object', bake_type: 'str'):
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(bake_obj)
                for slot_idx in range(len(bake_obj.material_slots)):
                        bake_obj.active_material_index = slot_idx
                        mat = bake_obj.material_slots[slot_idx].material
                        try:
                                self._ungroup_nodes_for_bake(mat)
                                self._edit_mat_for_bake(mat, bake_type)
                        except Exception as exc:
                                msg = 'Error editing material {0} (#{1}) for {2} bake object {3}' \
                                        .format(mat, slot_idx, bake_type, bake_obj)
                                raise RuntimeError(msg, mat, slot_idx, bake_type, bake_obj) from exc
        
        def _try_edit_mats_for_bake(self, bake_obj: 'Object', bake_type: 'str'):
                try:
                        self._edit_mats_for_bake(bake_obj, bake_type)
                except Exception as exc:
                        raise RuntimeError("_edit_mats_for_bake", bake_obj, bake_type) from exc
        
        def _bake_image(self, bake_type: 'str', target_image: 'Image'):
                _log.info("Preparing for bake atlas Image={0} type={1} size={2}...".format(
                        repr(target_image.name), bake_type, tuple(target_image.size)))
                
                # Поскольку cycles - ссанина, нам проще сделать копию ._bake_obj
                # Сделать копии материалов на ._bake_obj
                # Кастомизировать материалы, вывести всё через EMIT
                
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(self._bake_obj)
                _commons.ensure_op_finished(_bpy.ops.object.duplicate(linked=False), name='bpy.ops.object.duplicate')
                local_bake_obj = _C.view_layer.objects.active
                self._bake_obj.hide_set(True)
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(local_bake_obj)
                _commons.ensure_op_finished(_bpy.ops.object.make_single_user(
                        object=True, obdata=True, material=True, animation=False,
                ), name='bpy.ops.object.make_single_user')
                
                if bake_type in ('ALPHA', 'DIFFUSE', 'METALLIC', 'ROUGHNESS'):
                        self._try_edit_mats_for_bake(local_bake_obj, bake_type)
                
                for slot in local_bake_obj.material_slots:  # type: MaterialSlot
                        n_bake = _snodes.prepare_and_get_node_for_baking(slot.material)
                        n_bake.image = target_image
                
                emit_types = ('EMIT', 'ALPHA', 'DIFFUSE', 'METALLIC', 'ROUGHNESS')
                cycles_bake_type = 'EMIT' if bake_type in emit_types else bake_type
                _C.scene.cycles.bake_type = cycles_bake_type
                _C.scene.render.bake.use_pass_direct = False
                _C.scene.render.bake.use_pass_indirect = False
                _C.scene.render.bake.use_pass_color = False
                _C.scene.render.bake.use_pass_emit = bake_type in emit_types
                _C.scene.render.bake.normal_space = 'TANGENT'
                _C.scene.render.bake.margin = 64
                _C.scene.render.bake.use_clear = True
                
                self._call_before_bake_safe(bake_type, target_image)
                
                _log.info("Trying to bake atlas Image={0} type={1}/{2} size={3}...".format(
                        repr(target_image.name), bake_type, cycles_bake_type, tuple(target_image.size)))
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(local_bake_obj)
                bake_start = _perf_counter()
                _commons.ensure_op_finished(_bpy.ops.object.bake(type=cycles_bake_type, use_clear=True), name='bpy.ops.object.bake')
                bake_time = _perf_counter() - bake_start
                _log.info("Baked atlas Image={0} type={1}, time spent: {2:.1f} sec.".format(repr(target_image.name), bake_type, bake_time))
                
                garbage_materials = set(slot.material for slot in local_bake_obj.material_slots)
                mesh = local_bake_obj.data
                _C.blend_data.objects.remove(local_bake_obj, do_unlink=True)
                _C.blend_data.meshes.remove(mesh, do_unlink=True)
                for mat in garbage_materials:
                        _C.blend_data.materials.remove(mat, do_unlink=True)
                
                self._call_after_bake_safe(bake_type, target_image)
        
        def _bake_images(self):
                _commons.ensure_deselect_all_objects()
                _commons.activate_object(self._bake_obj)
                # Настраиваем UV слои под рендер
                for layer in _commons.get_mesh_safe(self._bake_obj).uv_layers:  # type: MeshUVLoopLayer
                        layer.active = layer.name == self._UV_ATLAS
                        layer.active_render = layer.name == self._UV_ORIGINAL
                        layer.active_clone = False
                for bake_type, target_image in self._bake_types:
                        self._bake_image(bake_type, target_image)
                        _gc_collect()
                
                _commons.ensure_deselect_all_objects()
                if self._bake_obj is not None:
                        # mesh = self._bake_obj.data
                        # bpy.context.blend_data.objects.remove(self._bake_obj, do_unlink=True)
                        # bpy.context.blend_data.meshes.remove(mesh, do_unlink=True)
                        pass
        
        def _get_target_material_safe(self, obj: 'Object', smat: 'Material'):
                tmat = None
                try:
                        tmat = self.get_target_material(obj, smat)
                # ...
                except Exception as exc:
                        msg = 'Can not get target material for {0} and {1}.'.format(obj, smat)
                        _log.error(msg)
                        raise RuntimeError(msg, smat, tmat) from exc
                return tmat
        
        def _apply_baked_materials(self):
                # TODO нужно как-то оптимизировать это говно
                # Для каждого материала смотрим каждый материала слот
                # Смотрим каждый полигон, если у него верный слот,
                # То берем средню UV и перебераем все transformы,
                # Нашли transform? запоминаем.
                # После обхода всех полигонов материала применяем transform на найденые индексы.
                
                _log.info("Applying UV...")
                mat_i, obj_i = 0, 0
                reporter = _LambdaReporter(self.report_time)
                reporter.func = lambda r, t: _log.info(
                        "Transforming UVs: Object={}/{}, Slots={}, Time={:.1f} sec, ETA={:.1f} sec...".format(
                                obj_i, len(self.objects), mat_i, t, r.get_eta(1.0 * obj_i / len(self.objects))))
                
                self._perf_find_transform = 0
                self._perf_apply_transform = 0
                self._perf_iter_polys = 0
                
                for obj in self.objects:
                        self._apply_baked_materials_on_obj(obj)
                        obj_i += 1
                        mat_i += len(obj.material_slots)
                        reporter.ask_report(False)
                        _commons.merge_same_material_slots(obj)
                reporter.ask_report(True)
                _log.info("Perf: find_transform: {0}, apply_transform: {1}, iter_polys: {2}".format(
                        self._perf_find_transform, self._perf_apply_transform, self._perf_iter_polys))
        
        def _apply_baked_materials_on_obj(self, obj: 'Object'):
                mesh = _commons.get_mesh_safe(obj)
                for material_index in range(len(mesh.materials)):
                        source_mat = mesh.materials[material_index]
                        transforms = self._transforms.get(source_mat)
                        if transforms is None:
                                continue  # Нет преобразований для данного материала
                        # Дегенеративная геометрия вызывает проблемы, по этому нужен epsilon.
                        # Зазор между боксами не менее epsilon материала, по этому возьмём половину.
                        epsilon = self._get_epsilon_safe(obj, source_mat)
                        src_size_x, src_size_y = self._matsizes[source_mat]
                        epsilon_x, epsilon_y = epsilon / src_size_x / 2, epsilon / src_size_y / 2
                        
                        maps = dict()  # type: Dict[int, UVTransform]
                        target_mat = self._materials.get((obj, source_mat))
                        uv_data = self._get_uv_data_safe(obj, source_mat, mesh)
                        
                        _t3 = _perf_counter()
                        for poly in mesh.polygons:
                                if poly.material_index != material_index:
                                        continue
                                mean_uv = _Vector((0, 0))
                                for loop in poly.loop_indices:
                                        mean_uv = mean_uv + uv_data[loop].uv
                                mean_uv /= len(poly.loop_indices)
                                transform = None
                                # Поиск трансформа для данного полигона
                                _t1 = _perf_counter()
                                for t in transforms:
                                        # Должно работать без эпсилонов
                                        if t.is_match(mean_uv, epsilon_x=epsilon_x, epsilon_y=epsilon_y):
                                                transform = t
                                                break
                                self._perf_find_transform += _perf_counter() - _t1
                                if transform is None:
                                        # Такая ситуация не должна случаться:
                                        # Если материал подлежал запеканию, то все участки должны были ранее покрыты трансформами.
                                        msg = 'No UV transform for Obj={0}, Mesh={1}, SMat={2}, Poly={3}, UV={4}, Transforms:' \
                                                .format(repr(obj.name), repr(mesh.name), repr(source_mat.name), repr(mean_uv), poly)
                                        _log.error(msg)
                                        for transform in transforms:
                                                _log.error('\t- {}'.format(repr(transform)))
                                        raise AssertionError(msg, obj, source_mat, poly, mean_uv, transforms)
                                for loop in poly.loop_indices:
                                        maps[loop] = transform
                        self._perf_iter_polys += _perf_counter() - _t3
                        # Применение трансформов.
                        _t2 = _perf_counter()
                        for loop, transform in maps.items():
                                # TODO по результатам замеров тут самая тормозная точка,
                                # надо подумать о том как это распараллелить или типа того.
                                vec2 = uv_data[loop].uv  # type: Vector
                                vec2 = transform.apply(vec2)
                                uv_data[loop].uv = vec2
                        self._perf_apply_transform += _perf_counter() - _t2
                        mesh.materials[material_index] = target_mat
                        obj.material_slots[material_index].material = target_mat
        
        def bake_atlas(self):
                """
                Run the baking process!
                """
                _log.info("Baking atlas!")
                self._prepare_objects()
                self._prepare_target_images()
                self._prepare_materials()
                self._prepare_matsizes()
                # Создайм вспомогательные дупликаты, они нужны только для
                # поиска UV островов
                self._make_duplicates()
                # Разбивка вспомогательные дупликатов по материалам
                self._separate_duplicates()
                # Может оказаться так, что не все материалы подлежат запеканию
                # Вспомогательные дупликаты с не нужными материалами удаляются
                self._cleanup_duplicates()
                # Оставщиеся вспомогательные дупликаты группируются по материалам
                self._group_duplicates()
                # Для каждого материала выполняем поиск островов
                self._find_islands()
                # После того, как острова найдены, вспомогательные дупликаты более не нужны, удаляем их
                self._delete_groups()
                # Острава нужно разместить на атласе.
                # Для этого используется mathutils.geometry.box_pack_2d
                # Для этого нужно сконвертировать
                self._create_transforms_from_islands()
                self._pack_islands()
                # Не трогаем исходники, создаем вспомогательный меш-объект для запекания
                self._prepare_bake_obj()
                self._bake_images()
                # После запекания к исходникам применяются новые материалы и преобразования
                self._apply_baked_materials()
                _log.info("Woohoo!")

Class variables

var BAKE_TYPES

Available types of baking layers for reference. Note, there is no DIFFUSE+ALPHA RGBA (use separate textures) and SMOOTHNESS (use ROUGHNESS instead) yet.

var ISLAND_TYPES

Available types of UV-Islands Searching for reference. Set per-Object and per-Material, see get_island_mode.

  • 'POLYGON' will try to use every polygon provided to find rectangular UV areas of Material. Allows to detect separated UV parts from same material and put it separately and more efficient on atlas. Slower, but can result dense and efficient packing.

  • 'OBJECT' will count each Material on each Object as single united island. Fast, but can pick large unused areas of Materials for atlasing and final atlas may be packed inefficient.

Instance variables

var objects

Mesh-Objects that will be atlassed.

var padding

Padding added to each UV Island around to avoid leaks.

var report_time

Minimum time between progress reports into logfile when running long and heavy operations.

var target_size

Size of atlas. Actually used only as aspect ratio. Your target images (See get_target_image) must match this ratio.

Methods

def after_bake(self, bake_type: str, target_image: Image)

This method is called after baking given type and Image. Note, Image obtained from 'get_target_image' is used for target_image. You can post-process something here, for example, save baked Image.

Expand source code
def after_bake(self, bake_type: str, target_image: 'Image'):
        """
        This method is called after baking given type and Image.
        Note, Image obtained from 'get_target_image' is used for `target_image`.
        You can post-process something here, for example, save baked Image.
        """
        pass
def bake_atlas(self)

Run the baking process!

Expand source code
def bake_atlas(self):
        """
        Run the baking process!
        """
        _log.info("Baking atlas!")
        self._prepare_objects()
        self._prepare_target_images()
        self._prepare_materials()
        self._prepare_matsizes()
        # Создайм вспомогательные дупликаты, они нужны только для
        # поиска UV островов
        self._make_duplicates()
        # Разбивка вспомогательные дупликатов по материалам
        self._separate_duplicates()
        # Может оказаться так, что не все материалы подлежат запеканию
        # Вспомогательные дупликаты с не нужными материалами удаляются
        self._cleanup_duplicates()
        # Оставщиеся вспомогательные дупликаты группируются по материалам
        self._group_duplicates()
        # Для каждого материала выполняем поиск островов
        self._find_islands()
        # После того, как острова найдены, вспомогательные дупликаты более не нужны, удаляем их
        self._delete_groups()
        # Острава нужно разместить на атласе.
        # Для этого используется mathutils.geometry.box_pack_2d
        # Для этого нужно сконвертировать
        self._create_transforms_from_islands()
        self._pack_islands()
        # Не трогаем исходники, создаем вспомогательный меш-объект для запекания
        self._prepare_bake_obj()
        self._bake_images()
        # После запекания к исходникам применяются новые материалы и преобразования
        self._apply_baked_materials()
        _log.info("Woohoo!")
def before_bake(self, bake_type: str, target_image: Image)

This method is called before baking given type and Image. Note, Image obtained from 'get_target_image' is used for target_image. You can prepare something here, for example, adjust Blender's baking settings.

Expand source code
def before_bake(self, bake_type: str, target_image: 'Image'):
        """
        This method is called before baking given type and Image.
        Note, Image obtained from 'get_target_image' is used for `target_image`.
        You can prepare something here, for example, adjust Blender's baking settings.
        """
        pass
def get_epsilon(self, obj: Object, mat: Material) ‑> Optional[float]

Should return precision value in pixel-space for given Object and Material. Note, size obtained from get_material_size is used for pixel-space.

Expand source code
def get_epsilon(self, obj: 'Object', mat: 'Material') -> 'Optional[float]':
        """
        Should return precision value in pixel-space for given Object and Material.
        Note, size obtained from `get_material_size` is used for pixel-space.
        """
        return None
def get_island_mode(self, origin: Object, mat: Material) ‑> str

Must return one of island search types. See ISLAND_TYPES for details.

Expand source code
def get_island_mode(self, origin: 'Object', mat: 'Material') -> 'str':
        """
        Must return one of island search types. See `ISLAND_TYPES` for details.
        """
        return 'POLYGON'
def get_material_size(self, src_mat: Material) ‑> Optional[Tuple[float, float]]

Must return size of material. This is relative compared with other Materials to figure out final area of Material on atlas. The real size of material will be different anyways.

You can use TexSizeFinder here.

Expand source code
def get_material_size(self, src_mat: 'Material') -> 'Optional[Tuple[float, float]]':
        """
        Must return size of material.
        This is relative compared with other Materials to figure out final area of Material on atlas.
        The real size of material will be different anyways.
        
        You can use `kawa_scripts.tex_size_finder.TexSizeFinder` here.
        """
        raise NotImplementedError('get_material_size')
def get_target_image(self, bake_type: str) ‑> Optional[Image]

Must return target Image for given bake type. Atlas Baker will bake atlas onto this Image. If Image is not provided (None or False) this bake type will not be baked. See BAKE_TYPES for available bake types.

Expand source code
def get_target_image(self, bake_type: str) -> 'Optional[Image]':
        """
        Must return target Image for given bake type.
        Atlas Baker will bake atlas onto this Image.
        If Image is not provided (None or False) this bake type will not be baked.
        See `BAKE_TYPES` for available bake types.
        """
        raise NotImplementedError('get_target_image')
def get_target_material(self, origin: Object, src_mat: Material) ‑> Material

Must return target Material for source Material. Ths source Material on this Object will be replaced with target Material after baking. Atlas Baker does not create final Materials by it's own. You should prepare target materials (with target images) and provide it here, so Atlas Baker can use and assign it.

Expand source code
def get_target_material(self, origin: 'Object', src_mat: 'Material') -> 'Material':
        """
        Must return target Material for source Material.
        Ths source Material on this Object will be replaced with target Material after baking.
        Atlas Baker does not create final Materials by it's own.
        You should prepare target materials (with target images) and provide it here, so Atlas Baker can use and assign it.
        """
        raise NotImplementedError('get_target_material')
def get_uv_name(self, obj: Object, mat: Material) ‑> Opional[str]

Should return the name of UV Layer of given Object and Material that will be used for baking. If not implemented (or returns None or False) first UV layer will be used. This layer will be edited to match Atlas and target Material.

Expand source code
def get_uv_name(self, obj: 'Object', mat: 'Material') -> 'Opional[str]':
        """
        Should return the name of UV Layer of given Object and Material that will be used for baking.
        If not implemented (or returns None or False) first UV layer will be used.
        This layer will be edited to match Atlas and target Material.
        """
        return  # TODO
class UVTransform

Internal class used by BaseAtlasBaker as mapping between UV areas on original Materials and UV areas on atlas.

Expand source code
class UVTransform:
        """
        Internal class used by `BaseAtlasBaker` as mapping between UV areas on original Materials and UV areas on atlas.
        """
        __slots__ = ('material', 'origin_norm', 'padded_norm', 'packed_norm')
        
        def __init__(self):
                # Хранить множество вариантов координат затратно по памяти,
                # но удобно в отладке и избавляет от велосипедов
                self.material = None  # type: Material
                # Оригинальная uv в нормальных координатах и в пикселях текстуры
                self.origin_norm = None  # type: Vector # len == 4
                # self.origin_tex = None  # type: Vector # len == 4
                # Оригинальная uv c отступами в нормальных и в пикселях текстуры
                self.padded_norm = None  # type: Vector # len == 4
                # self.padded_tex = None  # type: Vector # len == 4
                # packed использует промежуточные координаты во время упаковки,
                # использует нормализованные координаты после упаковки
                self.packed_norm = None  # type: Vector # len == 4
        
        def __str__(self) -> str: return common_str_slots(self, self.__slots__)
        
        def __repr__(self) -> str: return common_str_slots(self, self.__slots__)
        
        def is_match(self, vec2_norm: 'Vector', epsilon_x: 'float' = 0, epsilon_y: 'float' = 0):
                v = self.origin_norm
                x1, x2 = v.x - epsilon_x, v.x + v.z + epsilon_x
                y1, y2 = v.y - epsilon_y, v.y + v.w + epsilon_y
                return x1 <= vec2_norm.x <= x2 and y1 <= vec2_norm.y <= y2
        
        @staticmethod
        def _in_box(v2: 'Vector', box: 'Vector'):
                # Координаты vec2 внутри box как 0..1
                v2.x = (v2.x - box.x) / box.z
                v2.y = (v2.y - box.y) / box.w
        
        @staticmethod
        def _out_box(v2: 'Vector', box: 'Vector'):
                # Координаты 0..1 внутри box вне его
                v2.x = v2.x * box.z + box.x
                v2.y = v2.y * box.w + box.y
        
        def apply(self, vec2_norm: 'Vector') -> 'Vector':
                # Преобразование padded_norm -> packed_norm
                uv = vec2_norm.xy  # копирование
                self._in_box(uv, self.padded_norm)
                self._out_box(uv, self.packed_norm)
                return uv
        
        def iterate_corners(self) -> 'Generator[Tuple[int, Tuple[float, float]]]':
                # Обходу углов: #, оригинальная UV, атласная UV
                pd, pk = self.padded_norm, self.packed_norm
                yield 0, (pd.x, pd.y), (pk.x, pk.y)  # vert 0: left, bottom
                yield 1, (pd.x + pd.z, pd.y), (pk.x + pk.z, pk.y)  # vert 1: right, bottom
                yield 2, (pd.x + pd.z, pd.y + pd.w), (pk.x + pk.z, pk.y + pk.w)  # vert 2: right, up
                yield 3, (pd.x, pd.y + pd.w), (pk.x, pk.y + pk.w)  # vert 2: right, up