Blender Python: делаем пресеты для модификаторов

·

Возникла, в общем-то, тривиальная потребность: реализовать пресеты параметров для геонод в модификаторе объекта.

На самом деле всё просто — делаем класс-контейнер для параметров, и по необходимости копируем параметры из контейнера в инпуты модификатора по имени параметра.

Организация непосредственно контейнера и как их копировать/менеджить/отображать в интерфейсе это уже дело, так сказать, вкуса, но есть особые моменты касательно разбора модификаторов.

Копирование параметров модификатора Geometry Nodes

Так как параметры модификатора Geometry Nodes зависят от выбранных в нём геонод, получить к ним доступ через getattr() не выйдет — этих атрибутов у модификатора GeometryNodes нет.

Поэтому к параметрам можно получить доступ только по ключу, вроде modifier[key] или modifier.get(key). Запись также доступна только по ключу.

Однако сами ключи у них совершенно не информативные:

Python
>>>>list(C.object.modifiers['GeometryNodes'].keys())
['Input_18', 'Input_18_use_attribute', 'Input_18_attribute_name', 'Input_2', 'Input_5', 'Input_14', 'Input_19', 'Input_20', 'Input_12', 'Input_17', 'Input_6', 'Input_7', 'Input_9', 'Input_9_use_attribute', 'Input_9_attribute_name', 'Input_3', 'Input_3_use_attribute', 'Input_3_attribute_name', 'Input_4', 'Input_4_use_attribute', 'Input_4_attribute_name', 'Input_21', 'Input_21_use_attribute', 'Input_21_attribute_name', 'Input_16', 'Input_16_use_attribute', 'Input_16_attribute_name']

И как понять, где тут какой-нибудь ‘Stair Length’, а где ‘Stair Width’?

У объекта Geometry Node (самого дерева геонод) есть список всех инпутов с типом 'bpy.types.NodeSocketInterfaceGeometry'. У инпута есть два интересующих нас атрибута:

  • input.name — имя параметра в интерфейсе;
  • input.identifier — имя ключа этого параметра (тот самый ‘Input_’).

Чтобы получить словарь с удобными парами {дисплейное_имя:имя_ключа} у Node Group, подойдёт такая функция:

Python
def snake_case(string: str) -> str:
    return string.lower().replace(" ", "_")
    
def get_node_group_inputs(node_group) -> [str]:
    """Возвращает словарь, где ключ - имя переменной на экране,
    а значение - идентификатор"""
    input_dict = dict()
    for field in node_group.inputs:
        input_dict[snake_case(field.name)] = field.identifier
    return input_dict

В примере выше используется небольшая функция snake_case, чтобы изменить дисплейное имя на более полезное для работы: Stair Width -> stair_width. Это упрощает использование getattr() на контейнере для получения нужных значений при условии, что переменные в контейнере названы таким образом.

На выходе получим такую штуку:

Python
{'geometry': 'Input_0', 
'export_mode': 'Input_18', 
'sharp_angle': 'Input_9', 
'stair_length': 'Input_3',
'stair_width': 'Input_4',
'stair_thickness': 'Input_21',}
# Пример одной из геонод

Пример: нам нужен пресет для значений sharp_angle, stair_length, stair_width и stair_thickness. Тогда класс-контейнер может выглядеть так:

Python
class ModifierParamContainer(PropertyGroup):
    sharp_angle: FloatProperty(
        name="Stair Sharp Angle",
        min=0,
        max=180,
        default=30,
    )
    stair_length: FloatProperty(
        name="Stair Length",
        min=0.25,
        max=4,
        default=0.35,
    )
    stair_width: FloatProperty(
        name="Stair Width",
        min=0.15,
        max=50,
        default=1,
    )
    stair_thickness: FloatProperty(
        name="Stair Thickness",
        min=0.05,
        max=10000,
        default=0.15
    )

Обратите внимание, как названия атрибутов у контейнера совпадают с названиями атрибута геонод в snake_case. В таком случае можно обойти каждый инпут из списка и проверить, есть ли он в контейнере через hasattr(), и взять его, если он есть:

Копирование из контейнера
def copy_from_preset(modifier, preset):
    inputs = get_node_group_inputs(target_mod.node_group)
    for input_name, param_name in zip(inputs.values(), inputs.keys()):
        if hasattr(preset, param_name):
            modifier[input_name] = getattr(preset, param_name)
        else:
            print(f"Skip {input_name} ({param_name})")

Да, вот так вот просто. И при этом мы нигде не хардкодим названия того, что мы ищем — можно добавить в геоноду и контейнер новую переменную, и всё будет дальше работать. Переменные, которых нет в пресете, просто пропускаем.

Копирование параметров других модификаторов

С остальным модификаторами ситуация чуть запутаннее, но и тут ничего критически сложного.

У обычных модификаторов атрибуты «полноценные», т.е. объявлены в классе, и их не получить методами items(), keys() или values(), __annotations__ , поэтому чтобы получить список полей/атрибутов, тут нужно подойти с другой стороны — bl_rna.

Тема с bl_rna мне до сих пор не до конца понятна, однако по сути мы залезаем в API на уровень ниже и можем посмотреть структуру «внутренних» данных/их декларации. Сам метод честно подсмотрен на StackOverflow.

modifier.bl_rna.properties вернёт список всех переменных, включая доступные только для чтения.

bl_rna.properties модификатора Screw
>>> list(C.object.modifiers['Screw'].bl_rna.properties)
[<bpy_struct, PointerProperty("rna_type") at 0xcc5aae0>, <bpy_struct, StringProperty("name") at 0xcc5a9c0>, <bpy_struct, EnumProperty("type") at 0xcc5a8a0>, <bpy_struct, BoolProperty("show_viewport") at 0xcc5a780>, <bpy_struct, BoolProperty("show_render") at 0xcc5a660>, <bpy_struct, BoolProperty("show_in_editmode") at 0xcc5a540>, <bpy_struct, BoolProperty("show_on_cage") at 0xcc5a420>, <bpy_struct, BoolProperty("show_expanded") at 0xcc5a300>, <bpy_struct, BoolProperty("is_active") at 0xcc5a1e0>, <bpy_struct, BoolProperty("is_override_data") at 0xcc5a0c0>, <bpy_struct, BoolProperty("use_apply_on_spline") at 0xcc59fa0>, <bpy_struct, FloatProperty("execution_time") at 0xcc59e60>, <bpy_struct, PointerProperty("object") at 0xcc41b20>, <bpy_struct, IntProperty("steps") at 0xcc419e0>, <bpy_struct, IntProperty("render_steps") at 0xcc418a0>, <bpy_struct, IntProperty("iterations") at 0xcc41760>, <bpy_struct, EnumProperty("axis") at 0xcc41640>, <bpy_struct, FloatProperty("angle") at 0xcc41500>, <bpy_struct, FloatProperty("screw_offset") at 0xcc413c0>, <bpy_struct, FloatProperty("merge_threshold") at 0xcc41280>, <bpy_struct, BoolProperty("use_normal_flip") at 0xcc41160>, <bpy_struct, BoolProperty("use_normal_calculate") at 0xcc41040>, <bpy_struct, BoolProperty("use_object_screw_offset") at 0xcc40f20>, <bpy_struct, BoolProperty("use_merge_vertices") at 0xcc40e00>, <bpy_struct, BoolProperty("use_smooth_shade") at 0xcc40ce0>, <bpy_struct, BoolProperty("use_stretch_u") at 0xcc40bc0>, <bpy_struct, BoolProperty("use_stretch_v") at 0xcc40aa0>]

Ого, как много. Readonly-переменные тут нас не интересуют, т.к. мы их не поменяем, да и не особо нам это надо.

Python
>>> properties = [p.identifier for p in t if not p.is_readonly]
>>> properties
['name', 'show_viewport', 'show_render', 'show_in_editmode', 'show_on_cage', 'show_expanded', 'is_active', 'use_apply_on_spline', 'object', 'steps', 'render_steps', 'iterations', 'axis', 'angle', 'screw_offset', 'merge_threshold', 'use_normal_flip', 'use_normal_calculate', 'use_object_screw_offset', 'use_merge_vertices', 'use_smooth_shade', 'use_stretch_u', 'use_stretch_v']

И тут принцип работы контейнера и копирования аналогичен изложенному выше для геонод. Только вместо

modifier[input_name] = getattr(preset, param_name)
будет
setattr(modifier, param_name, getattr(preset, param_name))

Потому что теперь это полноценные атрибуты модификатора.

Live-edit множества модификаторов

Самой сложной задачей стал оператор множественного редактирования параметров геонод с отображением изменений в реальном времени.

Такой оператор должен быть с простой панелью, которая открывается при вызове оператора и показывает нужные параметры, а также позволяет менять режим редактирования: заменить параметры модификаторов указанными значениями или сложить/вычесть их.

Заполнить такую панель динамически созданными пропами в теории возможно, однако это всё надо как-то присоединить к оператору или вообще хоть куда-то (ведь в те же PropertyGroup добавлять атрибуты вне декларации класса не получится), и перспектива заниматься такими вещами не особо привлекает.

Альтернативой будет создание индивидуального класса-контейнера от PropertyGroup под каждый тип атрибута модификатора, который нам нужен (FloatProperty, BoolProperty, PointerProperty и т.д.), и создание CollectionProperty с элементами этого типа (ведь сделать CollectionProperty с типами данных из bpy.props просто так нельзя). Затем коллекцию можно динамически наполнять нужными пропами при вызове оператора, отрисовывать их, и после работы очищать список.

Python
class FloatContainer(bpy.types.PropertyGroup):
    """Контейнер индивидуальных float-значений"""
    value: bpy.props.FloatProperty(
        name="Value",
        min=0,
        max=180,
        default=30,
    )

class BatchEditorParameters(bpy.types.PropertyGroup):
    """Контейнер коллекций всех типов значений. В него 
    добавляем элементы по необходимости"""
    float_values: bpy.props.CollectionProperty(
        type=FloatContainer,
        name="Float Props"
    )

Однако в этом случае:

  1. Будет сложнее доставать значения по имени: нужно пробегать весь список пропов всех типов и искать там нужный элемент по имени объекта;
  2. Придётся поработать с индивидуальными min/max диапазонами значений.

Для ограниченного редактора всего 4 полей у конкретного модификатора это определённо слишком сложная система, и стрелять из пушки по воробьям не хотелось, поэтому контейнер был сделан всё же статическим:

Python
class LiveEditContainer(PropertyGroup):
    @property
    def sharp_angle_horizontal(self):
        return self.sharp_angle

    @property
    def replace_sharp_angle_horizontal(self):
        return self.replace_sharp_angle

    sharp_angle: FloatProperty(
        name="Stair Sharp Angle",
        default=0,
    )
    replace_sharp_angle: BoolProperty(
        name="Replace",
        default=False,
    )

    stair_length: FloatProperty(
        name="Stair Length",
        default=0,
    )
    replace_stair_length: BoolProperty(
        name="Replace",
        default=False,
    )

    stair_width: FloatProperty(
        name="Stair Width",
        default=0,
    )
    replace_stair_width: BoolProperty(
        name="Replace",
        default=False,
    )

    linear_stair_smoothing: BoolProperty(
        name="Linear Stair Smoothing",
        default=False,
    )

В таком варианте можно просто пробежаться по параметрам модификатора/инпутам геоноды и взять всё, что есть в контейнере. То есть проверяем наличие атрибута hasattr, и затем берём его getattr.

Такой контейнер можно легко дополнить какими-то полями без необходимости менять логику копирования данных в модификатор (да и куда угодно).


Само обновление модификаторов «на лету» проще всего было бы реализовать «костылём» через undo-панель: сразу по нажатию кнопки в интерфейсе срабатывает execute() метод оператора, в котором мы на выбранные объекты копируем данные из контейнера, и по нажатию на F6 открываем undo-панель и «на лету» эти параметры меняем.

Самый большой минус у этого способа — он медленный. Чем больше в сцене объектов, тем медленнее будет работать undo/redo. Это заметно даже в дефолтных операторах вроде Add Cylinder — попробуйте поменять значение Vertices у цилиндра после создания в большой сцене и ужаснитесь, насколько медленно оно меняется.

Значительно производительнее будет вариант ловить изменения какого-то атрибута через callback-функцию обновления:

Python
    sharp_angle: FloatProperty(
        name="Stair Sharp Angle",
        default=0,
        update=call_update
    )

У update-функции/метода есть доступ к контексту исполнения, из которого можно дернуть много полезной информации. В данном случае берём список выбранных объектов, ищем у них модификаторы нужного нам типа и копируем в них данные из контейнера:

В целом алгоритм «редактирования» такой:

  1. Пройти весь список выбранных объектов и найти объекты типа ‘MESH’ с модификатором типа ‘NODES’ с необходимым мне именем;
  2. Вызвать метод контейнера, который запишет в этот модификатор все значения атрибутов, которые есть в контейнере.
Python
def call_update(self, context):
  meshes = [obj for obj in context.selected_objects if 
            obj.type == 'MESH')
  for obj in meshes:
      for modifier in obj.modifiers:
          if modifier.type == 'NODES' and 
             modifier.node_group.name == target_name:
              self.set_values(modifier)
              break

Но в этом случае мы не можем, например, добавлять значения к исходным данным перед вызовом модификатора без наложения значений друг на друга, ведь мы о них ничего не знаем. Да и список объектов желательно заменить списком конечных модификаторов, чтобы не искать их на каждом изменении параметра.

Как прицепить произвольный объект к PropertyGroup

Изначально я пытался просто прицепить список нужных мне модификаторов и их первоначальных значений к объекту контейнера до того, как я отрисую панель с параметрами (о, какая наивность):

Python
def preprocess(self, targets):
    self.original_values = []
    self.modifiers = []
    for obj in targets:
        for modifier in obj.modifiers:
            # вот тут определяем, что это подходящий модификатор
            if self.valid_modifier(modifier):
                self.modifiers.append(modifier)
                self.original_values.append(self.get_vals(modifier))

Естественно ничего не вышло — PropertyGroup не позволяет создавать новые атрибуты за пределами декларации класса (запоминаем, это нам пригодится в будущем). Внутри preprocess атрибут есть, а потом — уже нет.

Аналогично не вышло и перетащить весь контейнер и все функции в оператор: self в invoke() и execute() методах отличался по адресу от call_update(), поэтому никакие переменные, заданные в invoke(), подцепить не удавалось.

Далее была попытка присоединить то, что мне нужно, к самой сцене:

Python
bpy.context.scene["live_edit_original_values"] = original_values
bpy.context.scene["live_edit_target_modifiers"] = modifiers

Но есть пара подводных камней:

  1. Список списков по типу [modifier, values] присоединить не выйдет — их как-то очень криво заворачивает в id_property и на выходе данные не разобрать;
  2. Присоединить ссылки на модификаторы тоже не выйдет — данные заворачиваются в id_property, и будет ошибка:
    TypeError: object of type 'NodesModifier' has no len();

Получается, что в лучшем случае мы сэкономим время на поиске нужных нам объектов внутри context.selected_objects, что как-то не очень.

И пока я писал эту заметку, на меня снизошло озарение: нельзя добавлять атрибуты вне декларации класса, но что если… добавить их прямо в декларации?

Python
class LiveEditContainer(PropertyGroup):
    @property
    def sharp_angle_horizontal(self):
        return self.sharp_angle

    @property
    def replace_sharp_angle_horizontal(self):
        return self.replace_sharp_angle

    sharp_angle: FloatProperty(
        name="Stair Sharp Angle",
        default=0,
    )
    replace_sharp_angle: BoolProperty(
        name="Replace",
        default=False,
    )

    stair_length: FloatProperty(
        name="Stair Length",
        default=0,
    )
    replace_stair_length: BoolProperty(
        name="Replace",
        default=False,
    )

    stair_width: FloatProperty(
        name="Stair Width",
        default=0,
    )
    replace_stair_width: BoolProperty(
        name="Replace",
        default=False,
    )

    linear_stair_smoothing: BoolProperty(
        name="Linear Stair Smoothing",
        default=False,
    )
    
    modifiers_data = []
    original_values = []

Затем в методе preprocess(), который мы вызываем из invoke() метода оператора и куда мы передаём уже готовый список нужных нам объектов под обработку мы в эти переменные пишем что нам надо:

Python
def preprocess(self, targets):
    for obj in targets:
        for modifier in obj.modifiers:
            if self.valid_modifier(modifier):
                values = self.get_shared_values(modifier)
                self.modifiers_data.append(modifier)
                self.original_values.append(values)

И… оно работает! Изначально я попытался писать сразу пары [modifier, values], но именно такая конструкция почему-то снова не работала и список оставался пустым. Зато раздельные списки — без проблем.

Такие переменные задавать очень удобно — PropertyGroup очень ограничен по возможностям, и для хранения каких-то временных данных он не особо подходит.

С другой стороны есть и минус — такие переменные не сохраняются вместе с .blend файлом, поэтому все данные в них в прямом смысле временные и пропадут после закрытия файла. А если их всё же надо сохранить, то придётся озаботиться этим отдельно.

При этом нигде в коде нельзя использовать операцию присвоения для этих переменных. Например self.modifier_data = [] создаст новый объект списка, который уже не будет работать. Для модификации используем только методы вроде .clear()/.pop и .append()/.extend()

Апдейт: вообще так и должно быть – операция присвоения приведёт к тому, что часть переменных будет ссылаться на старый объект, а часть – на новый. Но на момент написания этой заметки я про это не подумал.

Выводы

Резюмируя вышеизложенные мучения:

  1. У Geometry Nodes (у самих нод-групп) можно делать словари с парами {имя_на_экране:имя_ключа} — это значительно упрощает работу с процедурным копированием/записью атрибутов;
  2. У остальных модификаторов нужно залезать в bl_rna, чтобы выдернуть редактируемые атрибуты;
  3. Редактирование переменнных «в реальном времени» лучше реализовывать через update-callback на пропах;
  4. Чтобы прицепить атрибут не из bpy.props к PropertyGroup, его нужно указать прямо в декларации и не перезаписывать. Данные в нём не сохраняются на диске, но это удобное пространство для хранения временных данных без мороки с CollectionProperty.