Первая модель: модификаторы вместо ручных вершин
Канонический первый объект — кружка. Она удачна тем, что содержит все базовые приёмы: тело вращения, полость, изогнутая деталь, фаски, сглаживание.
Весь код ниже уезжает в аргумент code инструмента execute_blender_code.
Чистим сцену — но осторожно
import bpy
def clear():
# Не read_homefile(): перезагрузка файла посреди скрипта делает недействительным
# контекст, в котором этот же скрипт исполняется, и все последующие
# обращения к bpy.context падают.
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for coll in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.lights):
for d in list(coll):
if d.users == 0:
coll.remove(d)
Две ловушки сразу.
Первая — в комментарии. Очевидный способ очистить сцену,
bpy.ops.wm.read_homefile(use_empty=True), для кода, который прямо сейчас
исполняется, — самоубийство: перезагружается файл, а вместе с ним контекст.
Вторая — list(...). Удалять из коллекции, по которой идёт итерация, нельзя:
итератор сдвигается, часть элементов пропускается. Копия списка снимает вопрос.
И запретное, из https://courses.digitable.life/post/3d-pipeline/02-setup/: bpy.ops.wm.read_factory_settings()
вызывать нельзя никогда. Он сбрасывает настройки, а с ними отключает все
аддоны — включая тот, который вас обслуживает. Связь умирает до перезапуска.
Тело: примитив плюс модификаторы
import math
R, H, WALL = 0.040, 0.095, 0.004 # радиус, высота, толщина стенки в метрах
bpy.ops.mesh.primitive_cylinder_add(vertices=96, radius=R, depth=H,
location=(0, 0, H / 2))
body = bpy.context.active_object
body.name = "Mug"
location=(0, 0, H/2) ставит цилиндр на пол, а не по центру пола: примитив
создаётся симметрично вокруг своего центра, и без сдвига половина кружки уйдёт
под сцену.
Моделируйте в реальных единицах. Кружка радиусом 4 см — 0.040, а не 4.0. Масштаб всплывёт при экспорте (https://courses.digitable.life/post/3d-pipeline/06-export/), и разбираться потом дороже.
Вскрываем верх через bmesh
Кружка полая. Чтобы Solidify построил стенки, а не залил объём, сверху нужна
дыра:
import bmesh
me = body.data
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
top = max(bm.faces, key=lambda f: f.calc_center_median().z)
bmesh.ops.delete(bm, geom=[top], context='FACES')
bm.to_mesh(me)
bm.free()
bmesh — низкоуровневое представление меша, где есть прямой доступ к вершинам,
рёбрам и граням.
ensure_lookup_table()обязателен перед обращением по индексу. Без него — падение с невнятным сообщением.- Верхняя грань ищется по геометрии, а не по индексу:
maxпо Z-координате центра. Индексы зависят от версии и параметров примитива, координаты — нет. Это устойчивый способ, и агенту стоит писать именно так. bm.free()обязателен: bmesh не собирается сборщиком мусора.
Стенки, фаски, сглаживание
sol = body.modifiers.new("Solidify", 'SOLIDIFY')
sol.thickness = WALL
sol.offset = -1 # наращивать внутрь, сохраняя внешний силуэт
bev = body.modifiers.new("Bevel", 'BEVEL')
bev.width = 0.0012
bev.segments = 3
bev.limit_method = 'ANGLE'
bev.angle_limit = math.radians(35)
sol.offset = -1 — стенка растёт внутрь. При 0 она разошлась бы в обе стороны
и внешний радиус перестал бы равняться R.
bev.limit_method = 'ANGLE' — важнее, чем кажется. По умолчанию фаска ложится на
каждое ребро, включая те, что должны остаться острыми. Ограничение по углу
оставляет фаску только там, где грани сходятся резче 35°.
Порядок модификаторов имеет значение: Solidify → Bevel → Subsurf. Фаска после подразделения тратит геометрию на рёбра, которые уже скруглены.
Ручка: кривая вместо меша
Ручку можно вылепить из цилиндров. Не надо — кривая Безье с толщиной даёт гладкую трубку одной настройкой:
cu = bpy.data.curves.new("HandleCurve", 'CURVE')
cu.dimensions = '3D'
cu.bevel_depth = 0.0055 # радиус трубки
cu.bevel_resolution = 6 # гладкость сечения
cu.resolution_u = 16 # гладкость вдоль пути
sp = cu.splines.new('BEZIER')
sp.bezier_points.add(3) # add(n) добавляет n К уже существующей точке
bezier_points.add(3) даёт четыре точки: одна создаётся вместе со сплайном.
Классическая ошибка на единицу.
Сглаживание
for p in body.data.polygons:
p.use_smooth = True
bpy.ops.object.shade_auto_smooth(angle=math.radians(30))
Здесь версия имеет значение. Свойство mesh.use_auto_smooth, которое найдётся в
любом туториале старше пары лет, в 4.1 удалено — вместо него оператор
shade_auto_smooth. Проверено на 4.5.11: use_auto_smooth больше нет,
shade_auto_smooth есть.
Это типичная беда генерации кода языковой моделью: в обучающих данных полно примеров под старый API. Признак — падение на строке, которая «всегда работала». Лечится проверкой у самого Blender:
print(hasattr(bpy.types.Mesh, "use_auto_smooth"))
Не спорьте с интерпретатором — спрашивайте его.
Почему у агента выходит именно такой стиль
Получившийся код — не имитация ручного моделирования, а его альтернатива, и она хорошо ложится на агента:
- Параметры вместо вершин. «Стенку на миллиметр толще» — правка одной строки, а не перетаскивание сотни точек.
- Модификаторы неразрушающие. Плохо легла фаска — меняется число, а не переделывается меш.
- Код — это документация.
R, H, WALLвверху файла объясняют модель лучше любого описания и позволяют собрать вариации.
Отсюда и граница применимости из https://courses.digitable.life/post/3d-pipeline/00-overview/: агент силён там, где форма описывается числами, и слаб там, где нужен глаз.
Дальше — https://courses.digitable.life/post/3d-pipeline/04-seeing/: как агент смотрит на сделанное и как незаметно слепнет.