Пишем Python-макрос для FreeCAD
0. Предисловие
Недавно я решил начать осваивать 3D-моделирование. Выбрав FreeCAD в качестве наиболее оптимальной САПР для старта (а самое главное, работающей без проблем из-под Linux), я начал потихоньку ковыряться в простейших туториалах. Один мой друг, прознав о моем начинании, дал мне для практики небольшую задачку:
Отрисовать модель барабана для игрушечного пистолетика под 7 пулек
Дескать, барабан под шесть пулек просто, там геометрически очевидно расположение отверстий, а ты попробуй под семь отрисуй!
И, немного подумав, я отрисовал под 7:
А потом вспомнил, что freeCAD поддерживает создание макросов на питоне, и написал макрос для обобщённой задачи, который создаёт из произвольного цилиндра барабан под N пулек. При этом сам барабан может находиться совершенно в любой плоскости:
И хотя практическая польза такого макроса остаётся спорной, это неплохой способ разобраться в азах freeCAD, скриптах и автоматизации под эту САПР. Кому интересно — прошу под CAD:)
1. Сделаем ручками
Итак, нам нужно нарисовать барабан под N отверстий. Вот как мы отрисовали бы такой барабан вручную:
Создадим N+1 цилиндр (в верстаке Part). Один главный, с высотой 20 и диаметром 10, и N под отверстия (назовём их вспомогательными):
Придадим нужные размеры нашим цилиндрам, придадим вспомогательным нужные размеры и расположим их в «правильные» координаты. О том, как выбрать эти координаты, речь пойдет ниже. Для тех, кто хочет повторить все действия, вот таблица с координатами для 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
Объединим вспомогательные цилиндры в один объект (Part_Fuse или Union). Делается это так: в дереве проекта выделяем все цилиндры Cylinder001-Cylinder007 через Ctrl или Shift, после кликаем по инструменту Union в панели сверху. Получим новый объект Fuz:
Теперь применим к главному цилиндру и нашему новому объединению Fuz операцию Cut. Для этого выделяем сначала главный цилиндр, потом через Ctrl добавляем к выделению Fuz. И нажимаем Cut в панели инструментов:
Готово! Ничего сложного, если знать, в какие координаты поставить вспомогательные цилиндры :)
Теперь более подробно о том, как выбирать эти координаты:
2. Немного математики
Ясно, что для того, чтобы барабан у нас получился симметричный, необходимо, чтобы центры отверстий представляли из себя правильный N-угольник. А вокруг правильного N-угольника всегда можно описать окружность.
Пунктирная окружность описана вокруг центров отверстий. Внешняя окружность — границы барабана. На рисунке изображён 8-угольник, но все дальнейшие рассуждения верны и для любого правильного многоугольника.
Оставим на рисунке только многоугольник с окружностью и введем систему координат. Проведем вектор из центра координат в точку B, его угол с осью обозначим за .
Парой можно задать любую точку в пространстве точно так же, как и декартовыми координатами . Система координат, в которой точка задаётся вектором из центра координат и углом этого вектора с , называтся полярной. Из одной системы координат в другую можно перейти по следующей формуле:
Это становится очевидно, если посмотреть на рисунок выше и вспомнить определения синусов и косинусов (или вспомнить, что такое тригонометрический круг).
Теперь ясно, что координаты каждой из N точек мы можем легко выразить через полярные координаты. Вектор для всех точек одинаков и равен радиусу описаной окружности, а угол для каждой точки увеличивается на одинаковое значение, потому что диаметры правильного многоугольника делят окружность на равные части с углом в центре .
Итак, для каждой точки имеем:
Осталось только выбрать правильный радиус . Ведь если выбрать его слишком маленьким, то отверстия в барабане будут пересекаться друг с другом, а если слишком большим — выходить за границы барабана:
Предлагаю рассмотреть особенный случай, когда радиусы отверстий касаются (но не пересекают!) друг друга, а также касаются (но тоже не пересекают) внешней окружности барабана:
Что мы видим:
Из первого пункта получаем уравнение:
Из последних двух пунктов и простой тригонометрии получаем:
Решая систему, получаем, что если нам задан радиус барабана , мы можем вычислить нужный радиус описаной окружности по формуле:
Теперь мы можем спокойно открыть питончик и запилить в него функцию, которая получает на вход радиус барабана и количество отверстий, а возвращает координаты вспомогательных цилиндров и радиус их окружностей:
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. Согласно документации, есть три формата:
Угол, ось вращения и позиция.
Позиция (Position) определяет смещение центра объекта относительно начала координат. Ось вращения (Axis) задаёт вектор, воткруг которого вращается объект. Угол вращения (Angle) задаёт угол в градусах, на который мы поворачиваем объект вокруг оси вращения.
Например, если Axis = (1, 2, 0), Angle = 15, то объект поворачивается на 15 градусов вокруг прямой (или же вокруг вектора , что то же самое)Position, Angle, Pitch, Yaw
С Position всё понятно — это обычный вектор смещения, определяет положение в пространстве. А вот angle, pitch и yaw переводится как крен, рысканье и тангаж соответственно. Каким образом каждый из параметров вращает объект, проще понять по иллюстрации:Чаще всего так описывают вращение всего, что летает по воздуху: самолёты, планеры, квадрокоптеры и т.д. В нашем скрипте, впрочем, такое представление вращения нам не понадобится.
Matrix. Матрица Аффинных преобразований.
Это матрица, с помощью которой можно задавать смещение объекта относительно начала координат и угол поворота по , и , а ещё растяжение и сжатие объекта. Выглядит она вот так:
[ 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 ]
Если вкратце, столбец задаёт смещение. Т.е. . задают вращение вокруг осей координат, растяжение и сжатие. Как именно это происходит, можно почитать, например, тут или тут.Красота матриц афинных преобразований состоит в том, что чтобы преобразовать фигуру (сдвиг, поворот вокруг произвольной оси, комбинация сдвига и поворота и т.д.), достаточно умножить все её точки СПРАВА (порядок умножения важен) на матрицу A, задающую это преобразование:
Если же после этого мы решили ещё как-то повернуть/сдвинуть нашу фигуру, нам нужно всего лишь умножить получившиеся точки на другую матрицу B (тоже справа):
Теперь матрица получившегося преобразования — — произведение двух матриц. Этот факт нам очень понадобится при написании макроса.
У объектов 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 градусов вокруг оси .
Теперь матрица аффинных преобразований цилиндра выглядит так:
>>> 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 на матрицу цилиндра справа. Т.е. если — исходная матрица преобразования 'cylinder', то новая матрица
5. Пишем код
Перед запуском макроса надо как-то передать ему объекты, с которыми он будет работать. По моей задумке, пользователь должен выделить в дереве все объекты, коорые участвуют в преобразовании. Причем первым выделенным объектом должен быть главный цилиндр, а уже за ним выделяются побочные. Это можно сделать в одно действие через Shift.
Теперь скрипт сможет получить список объектов с помощью метода 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)