Возникла, в общем-то, тривиальная потребность: реализовать пресеты параметров для геонод в модификаторе объекта.
На самом деле всё просто — делаем класс-контейнер для параметров, и по необходимости копируем параметры из контейнера в инпуты модификатора по имени параметра.
Организация непосредственно контейнера и как их копировать/менеджить/отображать в интерфейсе это уже дело, так сказать, вкуса, но есть особые моменты касательно разбора модификаторов.
Копирование параметров модификатора Geometry Nodes
Так как параметры модификатора Geometry Nodes зависят от выбранных в нём геонод, получить к ним доступ через getattr() не выйдет — этих атрибутов у модификатора GeometryNodes нет.
Поэтому к параметрам можно получить доступ только по ключу, вроде modifier[key] или modifier.get(key). Запись также доступна только по ключу.
Однако сами ключи у них совершенно не информативные:
>>>>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, подойдёт такая функция:
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()
на контейнере для получения нужных значений при условии, что переменные в контейнере названы таким образом.
На выходе получим такую штуку:
{'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. Тогда класс-контейнер может выглядеть так:
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
вернёт список всех переменных, включая доступные только для чтения.
>>> 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-переменные тут нас не интересуют, т.к. мы их не поменяем, да и не особо нам это надо.
>>> 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 просто так нельзя). Затем коллекцию можно динамически наполнять нужными пропами при вызове оператора, отрисовывать их, и после работы очищать список.
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"
)
Однако в этом случае:
- Будет сложнее доставать значения по имени: нужно пробегать весь список пропов всех типов и искать там нужный элемент по имени объекта;
- Придётся поработать с индивидуальными min/max диапазонами значений.
Для ограниченного редактора всего 4 полей у конкретного модификатора это определённо слишком сложная система, и стрелять из пушки по воробьям не хотелось, поэтому контейнер был сделан всё же статическим:
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-функцию обновления:
sharp_angle: FloatProperty(
name="Stair Sharp Angle",
default=0,
update=call_update
)
У update-функции/метода есть доступ к контексту исполнения, из которого можно дернуть много полезной информации. В данном случае берём список выбранных объектов, ищем у них модификаторы нужного нам типа и копируем в них данные из контейнера:
В целом алгоритм «редактирования» такой:
- Пройти весь список выбранных объектов и найти объекты типа ‘MESH’ с модификатором типа ‘NODES’ с необходимым мне именем;
- Вызвать метод контейнера, который запишет в этот модификатор все значения атрибутов, которые есть в контейнере.
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
Изначально я пытался просто прицепить список нужных мне модификаторов и их первоначальных значений к объекту контейнера до того, как я отрисую панель с параметрами (о, какая наивность):
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()
, подцепить не удавалось.
Далее была попытка присоединить то, что мне нужно, к самой сцене:
bpy.context.scene["live_edit_original_values"] = original_values
bpy.context.scene["live_edit_target_modifiers"] = modifiers
Но есть пара подводных камней:
- Список списков по типу [modifier, values] присоединить не выйдет — их как-то очень криво заворачивает в id_property и на выходе данные не разобрать;
- Присоединить ссылки на модификаторы тоже не выйдет — данные заворачиваются в id_property, и будет ошибка:
TypeError: object of type 'NodesModifier' has no len()
;
Получается, что в лучшем случае мы сэкономим время на поиске нужных нам объектов внутри context.selected_objects, что как-то не очень.
И пока я писал эту заметку, на меня снизошло озарение: нельзя добавлять атрибуты вне декларации класса, но что если… добавить их прямо в декларации?
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() метода оператора и куда мы передаём уже готовый список нужных нам объектов под обработку мы в эти переменные пишем что нам надо:
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()
Апдейт: вообще так и должно быть – операция присвоения приведёт к тому, что часть переменных будет ссылаться на старый объект, а часть – на новый. Но на момент написания этой заметки я про это не подумал.
Выводы
Резюмируя вышеизложенные мучения:
- У Geometry Nodes (у самих нод-групп) можно делать словари с парами {имя_на_экране:имя_ключа} — это значительно упрощает работу с процедурным копированием/записью атрибутов;
- У остальных модификаторов нужно залезать в bl_rna, чтобы выдернуть редактируемые атрибуты;
- Редактирование переменнных «в реальном времени» лучше реализовывать через update-callback на пропах;
- Чтобы прицепить атрибут не из bpy.props к PropertyGroup, его нужно указать прямо в декларации и не перезаписывать. Данные в нём не сохраняются на диске, но это удобное пространство для хранения временных данных без мороки с CollectionProperty.