Пишем Python-макрос для FreeCAD

0. Предисловие

Недавно я решил начать осваивать 3D-моделирование. Выбрав FreeCAD в качестве наиболее оптимальной САПР для старта (а самое главное, работающей без проблем из-под Linux), я начал потихоньку ковыряться в простейших туториалах. Один мой друг, прознав о моем начинании, дал мне для практики небольшую задачку:

Отрисовать модель барабана для игрушечного пистолетика под 7 пулек

Дескать, барабан под шесть пулек просто, там геометрически очевидно расположение отверстий, а ты попробуй под семь отрисуй!
И, немного подумав, я отрисовал под 7:

mbpwx3ns8bto8wakm1kpqkubzqi.pngalmxkhmf8knhbnqi5hg_up6noaq.png

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

marl8sn0myg98cmeada1wupyt3q.png

И хотя практическая польза такого макроса остаётся спорной, это неплохой способ разобраться в азах freeCAD, скриптах и автоматизации под эту САПР. Кому интересно — прошу под CAD:)

1. Сделаем ручками

Итак, нам нужно нарисовать барабан под N отверстий. Вот как мы отрисовали бы такой барабан вручную:

  • Создадим N+1 цилиндр (в верстаке Part). Один главный, с высотой 20 и диаметром 10, и N под отверстия (назовём их вспомогательными):

    9vnbir0oqkqgaibqdk1b-pgjos4.pngsqptuwh2dlqy78ifmvfkz1wcqnc.png
  • Придадим нужные размеры нашим цилиндрам, придадим вспомогательным нужные размеры и расположим их в «правильные» координаты. О том, как выбрать эти координаты, речь пойдет ниже. Для тех, кто хочет повторить все действия, вот таблица с координатами для 7 отверстий:

    x y
    1: 6.97 0.0
    2: 4.35 5.45
    3: -1.55 6.8
    4: -6.28 3.03
    5: -6.28 -3.03
    6: -1.55 -6.8
    7: 4.35 -5.45

qxyjexznaqwcqthxiavh2bczclg.png

  • Объединим вспомогательные цилиндры в один объект (Part_Fuse или Union). Делается это так: в дереве проекта выделяем все цилиндры Cylinder001-Cylinder007 через Ctrl или Shift, после кликаем по инструменту Union в панели сверху. Получим новый объект Fuz:

    toewutxuliz06lxs4kzjo8btaku.png-wl-yd1ne2xdaaokdzqkynanaty.png
  • Теперь применим к главному цилиндру и нашему новому объединению Fuz операцию Cut. Для этого выделяем сначала главный цилиндр, потом через Ctrl добавляем к выделению Fuz. И нажимаем Cut в панели инструментов:

    c9azfjzjqlzu8jokmn53t3w6cju.pngrmds6uyxmjw4tdfntjxmamxo5kk.png

Готово! Ничего сложного, если знать, в какие координаты поставить вспомогательные цилиндры :)

Теперь более подробно о том, как выбирать эти координаты:

2. Немного математики

Ясно, что для того, чтобы барабан у нас получился симметричный, необходимо, чтобы центры отверстий представляли из себя правильный N-угольник. А вокруг правильного N-угольника всегда можно описать окружность.

zm6vhvwwwpnmez1agwofqgwyyew.png

Пунктирная окружность описана вокруг центров отверстий. Внешняя окружность — границы барабана. На рисунке изображён 8-угольник, но все дальнейшие рассуждения верны и для любого правильного многоугольника.

Оставим на рисунке только многоугольник с окружностью и введем систему координат. Проведем вектор \vec r из центра координат в точку B, его угол с осью Ox обозначим за α.

ccj7govdjh0dj2ps9jxogwu1th8.png

Парой (α, r) можно задать любую точку в пространстве точно так же, как и декартовыми координатами (x, y). Система координат, в которой точка задаётся вектором из центра координат и углом этого вектора с Ox, называтся полярной. Из одной системы координат в другую можно перейти по следующей формуле:

x = r*cos\alpha,y = r*sin\alpha

Это становится очевидно, если посмотреть на рисунок выше и вспомнить определения синусов и косинусов (или вспомнить, что такое тригонометрический круг).

Теперь ясно, что координаты каждой из N точек мы можем легко выразить через полярные координаты. Вектор r для всех точек одинаков и равен радиусу описаной окружности, а угол \alpha для каждой точки увеличивается на одинаковое значение, потому что диаметры правильного многоугольника делят окружность на равные части с углом в центре \alpha = \frac{2\pi}{N}.

Итак, для каждой точки N_i имеем:

x=r*cos(\frac{2\pi i}{N}), y=r*sin(\frac{2\pi i}{N}), i=0, 1,...,N-1(1)

Осталось только выбрать правильный радиус r. Ведь если выбрать его слишком маленьким, то отверстия в барабане будут пересекаться друг с другом, а если слишком большим — выходить за границы барабана:

prxn4vvykqc0j6grh7fznck0lfi.png

Предлагаю рассмотреть особенный случай, когда радиусы R' отверстий касаются (но не пересекают!) друг друга, а также касаются (но тоже не пересекают) внешней окружности барабана:

bfknjxgxftfgrwk-79zkebkdzce.png

Что мы видим:

Из первого пункта получаем уравнение:
R=r+R'
Из последних двух пунктов и простой тригонометрии получаем:

R' = r*\sin{\frac{\alpha}{2}}

Решая систему, получаем, что если нам задан радиус барабана R, мы можем вычислить нужный радиус описаной окружности r по формуле:

r = \frac{R}{1+\sin{\frac{\alpha}{2}}}(2)

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

def calculate_sizes(radius, amount):
	alpha = math.radians(360/int(amount))
	
	#Применяем формулу (2):
	r = float(radius)/(1+math.sin(alpha/2)) 
	
	vals = []
	#Применяем фыормулу (1):
	for i in range(0,int(amount)):
		vals.append((round(r*math.cos(alpha*i), 2),
					round(r*math.sin(alpha*i), 2))) 
	little_rad = r*math.sin(360/int(amount)/2)
	return vals, little_rad*0.9

Поскольку мы хотим оставить хоть какой-то зазор между отверстиями, радиус отверстий надо сделать чуть меньше, чем в мат. расчётах. Это и отражено в последней строке кода (коэффициент 0.9 я взял от балды, ведь мы делаем не настоящий барабан, а метафизический :)).

3. Изучаем модуль FreeCad

Поговорим немного об объектах и методах питоньего модуля freeCAD, которые нам понадобятся. У freeCAD есть очень полезная фича — консоль Python, отображающая все команды, которые вызываются от наших действий в GUI. Включить её можно через View→Panels→python console.

Консоль, методы объектов

Если вы проделали все шаги из пункта 1 руками, то в консоли вы увидите все команды Python, которые вызывались в этот момент приложением. Например, чтобы задать радиус цилиндра 'Cylinder', использовалась команда:

FreeCAD.getDocument('Unnamed').getObject('Cylinder').Radius = '10.00 mm'

А когда мы нажимали на инструмент Cut, вызывался следующий скрипт:

>>> App.activeDocument().addObject("Part::Cut","Cut")
>>> App.activeDocument().Cut.Base = App.activeDocument().Cylinder
>>> App.activeDocument().Cut.Tool = App.activeDocument().Fuz001
>>> Gui.activeDocument().Cylinder.Visibility=False
>>> Gui.activeDocument().Fuz001.Visibility=False
>>> App.getDocument('Unnamed').getObject('Cut').ViewObject.ShapeColor=getattr(App.getDocument('Unnamed').getObject('Cylinder').getLinkedObject(True).ViewObject,'ShapeColor',App.getDocument('Unnamed').getObject('Cut').ViewObject.ShapeColor)
>>> App.getDocument('Unnamed').getObject('Cut').ViewObject.DisplayMode=getattr(App.getDocument('Unnamed').getObject('Cylinder').getLinkedObject(True).ViewObject,'DisplayMode',App.getDocument('Unnamed').getObject('Cut').ViewObject.DisplayMode)
>>> App.ActiveDocument.recompute()

Разберем скрипт подробнее:

  • Первая команда создаёт объект класса Cut, который наследуется от класса Part, и задаёт ему имя «Cut»

  • Вторая и третья команда делает главный цилиндр Cylinder базой объекта, а объект Fuz (то есть объединение наших вспомогательный цилиндров Cylinder001-Cylinder007) — инструментом, то бишь Tool. Таким образом, Cylinder становится объектом, из которого мы вырезаем какую-то часть, а объединение цилдиндров Fuz становится объектом, который мы вырезаем из Cylinder.

  • Четвёртая и пятая строки делают Cylinder и Fuz невидимыми, чтобы на экране у пользователя отображался только результат вырезания Cut.

  • Строки 6 и 7 передают новому объекту Cut параметры цвета ShapeColor и способа отображения DisplayMode, чтобы новый объект выглядел визульно так же, как и базовый объект Cylinder. Параметры ShapeColor и DisplayMode можно посмотреть во вкладке View в левой части интеофейса freeCAD.

Примерно похожая картина и при использовании инструмента Union (когда мы объединяли вспомогательные цилиндры в один объект). После создания нового объекта элементы объединения записываются в поле Shapes:

App.activeDocument().Fusion.Shapes = [App.activeDocument().Cylinder001,App.activeDocument().Cylinder002,App.activeDocument().Cylinder003,App.activeDocument().Cylinder004,App.activeDocument().Cylinder005,App.activeDocument().Cylinder006,App.activeDocument().Cylinder007,]

Поскольку тут нет разделения на Base и Tool, все объекты хранятся одним списком.

Эти методы будут непосредственно использоваться в нашем скрипте.

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

4. Положение объекта в пространстве. Ещё немного математики

Обсудим ещё одну важную вещь — как внутри класса определяется положение и ориентация объекта в простанстве. У объектов FreeCAD за всё это отвечает параметр Placement. Согласно документации, есть три формата:

  1. Угол, ось вращения и позиция.
    Позиция (Position) определяет смещение центра объекта относительно начала координат. Ось вращения (Axis) задаёт вектор, воткруг которого вращается объект. Угол вращения (Angle) задаёт угол в градусах, на который мы поворачиваем объект вокруг оси вращения.
    Например, если Axis = (1, 2, 0), Angle = 15, то объект поворачивается на 15 градусов вокруг прямой y=2x, z=0 (или же вокруг вектора \vec v = (1, 2, 0), что то же самое)

  2. Position, Angle, Pitch, Yaw
    С Position всё понятно — это обычный вектор смещения, определяет положение в пространстве. А вот angle, pitch и yaw переводится как крен, рысканье и тангаж соответственно. Каким образом каждый из параметров вращает объект, проще понять по иллюстрации:

    2fk20jdgtxv9dyiey7l1q0w_j8w.png

    Чаще всего так описывают вращение всего, что летает по воздуху: самолёты, планеры, квадрокоптеры и т.д. В нашем скрипте, впрочем, такое представление вращения нам не понадобится.

  3. Matrix. Матрица Аффинных преобразований.
    Это матрица, с помощью которой можно задавать смещение объекта относительно начала координат и угол поворота по Ox, Oy и Oz, а ещё растяжение и сжатие объекта. Выглядит она вот так:
    [ R_11 R_12 R_13 T1]
    |R_21 R_22 R_23 T2|
    |R_31 R_32 R_33 T3|
    [ 0 0 0 1 ]
    Если вкратце, столбец (T1, T2, T3, 1)^T задаёт смещение. Т.е. (T1, T2, T3)=(x, y, z). R_{ij} задают вращение вокруг осей координат, растяжение и сжатие. Как именно это происходит, можно почитать, например, тут или тут.

    Красота матриц афинных преобразований состоит в том, что чтобы преобразовать фигуру (сдвиг, поворот вокруг произвольной оси, комбинация сдвига и поворота и т.д.), достаточно умножить все её точки (x, y)^T СПРАВА (порядок умножения важен) на матрицу A, задающую это преобразование:

    (x_1, y_1)^T =A*(x, y)^T

    Если же после этого мы решили ещё как-то повернуть/сдвинуть нашу фигуру, нам нужно всего лишь умножить получившиеся точки на другую матрицу B (тоже справа):

(x_2, y_2)=B*(x_1, y_1)^T =BA*(x, y)^T

Теперь матрица получившегося преобразования — BA — произведение двух матриц. Этот факт нам очень понадобится при написании макроса.

У объектов FreeCAD, наследующихся от Part (Например, Cylinder, Sphere, Cone и т.д.) есть параметр Placement, в который и хранит их смещение и углы поворота.
Поворот и смещение можно придать объекту вот так (Задаём позицию, ось вращения и угол):

>>>cylinder = App.ActiveDocument.getObject('Cylinder')
>>>cylinder.Placement.Base = App.Vector(10, 10, 10)
>>>cylinder.Placement.Rotation = App.Rotation(App.vector(0, 1, 0), 60)

В этом примере мы передвинули цилиндр в точку (10, 10, 10) и повернули его на 60 градусов вокруг оси Oy.
Теперь матрица аффинных преобразований цилиндра выглядит так:

>>> cylinder.Placement.Matrix
Matrix ((0.5,-0.612372,0.612372,10),(0.612372,0.75,0.25,10),(-0.612372,0.25,0.75,10),(0,0,0,1))

Если бы мы хотели ещё как-то подвинуть и покрутить этот цилиндр и у нас была матрица этого преобразования M, мы могли бы просто умножить две матрицы:

>>>cylinder.Placement = cylinder.Placement.multiply(M)

Строка выше умножает M на матрицу цилиндра справа. Т.е. если A — исходная матрица преобразования 'cylinder', то новая матрица A1 = M*A

5. Пишем код

  • Перед запуском макроса надо как-то передать ему объекты, с которыми он будет работать. По моей задумке, пользователь должен выделить в дереве все объекты, коорые участвуют в преобразовании. Причем первым выделенным объектом должен быть главный цилиндр, а уже за ним выделяются побочные. Это можно сделать в одно действие через Shift.

    lroopyvsu2xiovp3bezowhzjcms.png
  • Теперь скрипт сможет получить список объектов с помощью метода getSelection ():

def get_selection():
	#Список выделенных объектов. selection[0] макрос считает за главный
    selection = Gui.Selection.getSelection()
    #Проверяем, все ли объекты являются цилиндрами
	#Если не все, выводим ошибку
    for obj in selection:
        if obj.Name[:8] != 'Cylinder':
            raise ValueError
    return selection
  • Передаём радиус главного цилиндра и количество вспомогательных цилиндров в уже известную нам фунцию set_cyl() и получаем список координат (x, y) для вспомогательных цилиндров и их радиус.

positions, lilrad = calculate_sizes(main_cyl.Radius, len(selection)-1)
  • Далее создаём цикл по каждому вспомогательному цилиндру и задаём ему нужные размеры, а ещё координаты и вращение. Здесь нам и пригодится матрица афинных преобразований:

def set_cyl(cyl, main, pos radius):
	#Присваеваем вспомогательному цилиндру радиус,
	#вычисленный в calculate_sizes(),
	#остальные параметры уопируем у главного цилиндра
	cyl.Radius = radius
	cyl.Height = main.Height
	cyl.FirstAngle = main.FirstAngle
	cyl.SecondAngle = main.SecondAngle
	#на всякий случай обнуляем вращение и смещение cyl
	cyl.Placement.Base = App.Vector(pos[0], pos[1], 0)
	cyl.Placement.Rotation = App.Rotation(App.Vector(0, 0, 0), 0)
	#умножаем матрицу main на матрицу cyl СЛЕВА
	#C = M*C
	cyl.Placement = main.Placement.multiply(cyl.Placement)

'''...Some code in between...'''

for i in range(len(selection)-1):
    set_cyl(selection[i+1], main_cyl, positions[i], lilrad)

Дело в том, что перед вызовом макроса пользователь может задать главному цилиндру любое расположение, а ещё повернуть его как ему вздумается вокруг произвольной оси. А функция calculate_sizes() вычисляла координаты вспомогательных цилиндров для случая, когда главный цилиндр расположен без поворотов и в начале координат.
Поэтому сначала мы придаём цилиндру смещение 'pos' и на всякий случай обнуляем все углы вращения вокруг осей (и если бы наш барабан стоял вертикально в центре координат, отверстие вписалось бы как надо). А для того, чтобы придать цилиндру поворот и смещение барабана, мы умножаем матрицу поворота барабана на матрицу поворота цилиндра СПРАВА и присваеваем цилиндру получившуюся матрицу.

def fusion(shapes, fn):
	#Добавляем объект MultiFuse
	fus_obj = App.activeDocument().addObject("Part::MultiFuse",fn)
	#Загоняем в него второстепенные цилиндры
	fus_obj.Shapes = shapes[1:]
	#Делаем цилиндры невидимыми
	for shape in shapes[1:]:
		shape.Visibility=False
	return fus_obj


def perform_cut(selection):
	#делаем название объектов FuzXXX и CutXXX уникальными
    cn, fn = get_good_names() 
    fus_obj = fusion(selection, fn)
    main = selection[0]
    #Добавляем Cut
    cut = App.activeDocument().addObject("Part::Cut",cn)
    #В Base кладем главный цилиндр, в Name - побочные
    cut.Base = App.activeDocument().getObjectsByLabel(main.Name)[0]
    cut.Tool = App.activeDocument().getObjectsByLabel(fus_obj.Name)[0]

6. Код целиком

# -*- coding: utf-8 -*-
import FreeCAD
import Part
import math
__title__   = "pistol_drum"
__author__  = "T"

def get_selection():
    selection = Gui.Selection.getSelection()
    for obj in selection:
        if obj.Name[:8] != 'Cylinder':
            raise ValueError
    return selection


def calculate_sizes(radius, amount):
    angle = 360/int(amount)
    angle_rad = math.radians(angle)
    r = float(radius)/(1+math.sin(angle_rad/2))
    vals = []
    for i in range(0,int(amount)):
        vals.append((round(r*math.cos(angle_rad*i), 2),
                     round(r*math.sin(angle_rad*i), 2)))
    little_rad = r*math.sin(angle_rad/2)
    return vals, little_rad*0.9


def set_cyl(cyl, main, pos, radius):
    cyl.Radius = radius
    cyl.Height = main.Height
    cyl.FirstAngle = main.FirstAngle
    cyl.SecondAngle = main.SecondAngle
    cyl.Placement.Base = App.Vector(pos[0], pos[1], 0)
    cyl.Placement.Rotation = App.Rotation(App.Vector(0, 0, 0), 0)
    cyl.Placement = main.Placement.multiply(cyl.Placement)


def get_good_names():
	''' 
		Проходим через список существующих объектов и 
		выбираем для Fuz и Cut наименьший из возможноых постфиксов,
		чтобы имена не совпадали
	'''
    l = App.activeDocument().Objects
    cut = 0
    fus = 0
    for o in l:
        s = o.Label
        if s.startswith("Cut"):
            cut = max(cut, int(o.Label[-3:]))
        elif s.startswith("Fusion"):
            fus = max(fus, int(o.Label[-3:]))
    cutname = f"Cut{str(cut+1).zfill(3)}"
    fuzname = f"Fuz{str(fus+1).zfill(3)}"
    return cutname, fuzname

			

def fusion(shapes, fn):
	fus_obj = App.activeDocument().addObject("Part::MultiFuse",fn)
	fus_obj.Shapes = shapes[1:]
	for shape in shapes[1:]:
		shape.Visibility=False
	return fus_obj


def perform_cut(selection):
    cn, fn = get_good_names()
    fus_obj = fusion(selection, fn)
    main = selection[0]
    cut = App.activeDocument().addObject("Part::Cut",cn)
    cut.Base = App.activeDocument().getObjectsByLabel(main.Name)[0]
    cut.Tool = App.activeDocument().getObjectsByLabel(fus_obj.Name)[0]




selection = get_selection()
main_cyl = selection[0]
position, lilrad = calculate_sizes(main_cyl.Radius, len(selection)-1)

for i in range(len(selection)-1):
    set_cyl(selection[i+1], main_cyl, position[i], lilrad)


perform_cut(selection)

© Habrahabr.ru