Привет, друзья! Сегодня я расскажу вам, как своими руками написать небольшое расширение для известной САПР Fusion 360.
Хоть Autodesk и не работает на территории РФ, сам Fusion 360 вполне функционирует, да и бесплатную хоббийную лицензию на него получить все еще можно, так что, надеюсь, статья найдет своего читателя.
Немного предыстории: увлекаюсь я, кроме разработки, еще и робототехникой и 3-д печатью. А робототехника требует, если у вас конечно нет 100500 денег на готовые сервоприводы, изготовления механических редукторов. И редукторы те должны быть с большим передаточным числом, потому что моторы с большим моментом тоже зело недешевые. В основном все хоббийщики делают либо волновые, либо циклоидальные редукторы, особенно, конечно, циклоидальные - они почти идеальны для домашнего производства на 3-д принтере. Но, мне как-то на просторах интернета попался еще один тип редуктора - волновой с промежуточными телами качения (далее по тексту буду использовать аббревиатуру ВПТК). Так вот, если для построения циклоидных редукторов существует великое множество аддонов и скриптов для того же фьюжена, то для ВПТК таких не нашлось, нашелся только скрипт для генерации профиля циклоиды с сохранением в dxf. В принципе этого бы и хватило, но делать особо было нечего, и я решил сделать аддон для Fusion 360, который бы строил этот редуктор целиком.
Шаг первый. Создаем новый аддон
Для начала надо перейти на вкладку UTILITIES и найти там иконку "scripts and add-ins", если на нее нажать, то откроется такое окно:

где и нужно выбрать "create script or addin".

Заполнить название, автора и прочую информацию, если хотите. Fusion создаст шаблонный проект с приблизительно вот такой структурой:

Открываем этот проект своей любимой IDE (я использовал PyCahrm). После чего удаляем ненужные нам каталоги с учебными примерами команд, и создаем каталог для своей коменды (я ее назвал createWaveDrive). Скопипастим в него файлы init.py, entry.py и каталог resources из шаблонной папки commandDialog. Так же надо отредактировать файл init.py в каталоге commands убрав лишние импорты и добавив свой:
#это удаляем:
from .commandDialog import entry as commandDialog
from .paletteShow import entry as paletteShow
from .paletteSend import entry as paletteSend
#а это добавляем:
from .createWaveDrive import entry as createWaveDrive
commands = [
#и это удаляем:
commandDialog,
paletteShow,
paletteSend
#а это добавляем:
createWaveDrive
]
по итогу структура проекта окажется примерно такой:

Шаг второй. Создаем пользовательский интерфейс
Теперь нам надо создать интрефейс нашего аддона: кнопку вызова диалога создания редуктора и сам диалог. Для этого нам нужно перейти в файл entry.py.
Отредактируем константы под наше расширение:
CMD_ID = f'{config.COMPANY_NAME}_{config.ADDIN_NAME}_waveDriveDialog'
CMD_NAME = 'Wave Drive Creation Dialog'
CMD_Description = 'Create wave drive with roller elements'
И удалим тела функций command_created, command_execute, command_input_changed, command_validate_input:
def command_created(args: adsk.core.CommandCreatedEventArgs):
pass
def command_execute(args: adsk.core.CommandEventArgs):
pass
def command_input_changed(args: adsk.core.InputChangedEventArgs):
pass
def command_validate_input(args: adsk.core.ValidateInputsEventArgs):
pass
Они нам пока не нужны. Функцию command_destroy оставляем как есть - ее менять не потребуется.
Немного отредактируем функцию start (подробно на ней останавливаться не будем, она достаточно очевидна):
по умолчанию кнопка аддона добавляется рядом с кнопкой "scripts and addins", что не очень наглядно, перенесем ее на свою личную панель.
вместо
panel = workspace.toolbarPanels.itemById(PANEL_ID)
напишем
panels = workspace.toolbarPanels
panel = panels.itemById(PANEL_ID)
if panel:
panel.deleteMe()
panel = panels.add(PANEL_ID, 'ROLLER WAVE DRIVE', 'SelectPanel', False)
По желанию можно отредактировать иконки в каталоге resources, я заменил их на иконки передачи. Теперь при запуске расширения будет появляться кнопка на дополнительной панели справа.

Добавим реакцию на нажатие. Для начала добавим константы идентификаторов полей ввода диалога:
ID_ROLLER_DIAMETER = 'roller_diameter'
ID_ROLLERS_NUMBER = 'rollers_number'
ID_USE_BALLS = 'use_balls'
ID_ROLLER_HEIGHT = 'roller_height'
ID_USE_MINIMAL_DIAMETER = 'use_minimal_diameter'
ID_CYCLOID_DIAMETER = 'cycloid_diameter'
ID_INPUT_SHAFT_DIAMETER = 'input_shaft_diameter'
ID_INPUT_PLANE = 'input_plane'
ID_ROLLER_TOLERANCE = 'roller_tolerance'
ID_BODY_DIAMETER = 'body_diameter'
ID_BEARING_OUTER_DIAMETER = 'bearing_outer_diameter'
ID_BEARING_INNER_DIAMETER = 'bearing_inner_diameter'
ID_BEARING_HEIGHT = 'bearing_height'
после чего в функции command_created создадим, собственно, диалог:
#сохраним единицы длины выставленные в системе. можно указывать явно строками 'cm', 'mm', 'in'
len_units = app.activeProduct.unitsManager.defaultLengthUnits
#ссылка на контейнер элементов управления
inputs = args.command.commandInputs
# Создаем диалог
# Сверху добавим небольшое схематическое изображение передачи
inputs.addImageCommandInput('image', '', 'commands/createWaveDrive/resources/diagram.png')
# Поле ввода для чисел, по умолчанию 6
inputs.addValueInput(ID_ROLLER_DIAMETER, 'Roller diameter', len_units, adsk.core.ValueInput.createByString('6'))
# Поле ввода целых чисел из диапазона со стрелками инкремена и декремента
inputs.addIntegerSpinnerCommandInput(ID_ROLLERS_NUMBER, 'Rollers number', 5, 100, 1, 17)
# Чекбокс
inputs.addBoolValueInput(ID_USE_BALLS, 'Use balls', True, '', False)
inputs.addValueInput(ID_ROLLER_HEIGHT, 'Roller height', len_units, adsk.core.ValueInput.createByString('6'))
inputs.addBoolValueInput(ID_USE_MINIMAL_DIAMETER, 'Use minimal cycloid diameter', True, '', False)
inputs.addValueInput(ID_CYCLOID_DIAMETER, 'Cycloid outer diameter', len_units, adsk.core.ValueInput.createByString('75'))
inputs.addValueInput(ID_BODY_DIAMETER, 'Body diameter', len_units, adsk.core.ValueInput.createByString('80'))
inputs.addValueInput(ID_INPUT_SHAFT_DIAMETER, 'Input shaft diameter', len_units, adsk.core.ValueInput.createByString('5'))
inputs.addValueInput(ID_ROLLER_TOLERANCE, 'Rollers tolerance', len_units, adsk.core.ValueInput.createByString('0.1'))
inputs.addValueInput(ID_BEARING_OUTER_DIAMETER, 'Bearing outer diameter', len_units, adsk.core.ValueInput.createByString('21'))
inputs.addValueInput(ID_BEARING_INNER_DIAMETER, 'Bearing inner diameter', len_units, adsk.core.ValueInput.createByString('12'))
inputs.addValueInput(ID_BEARING_HEIGHT, 'Bearing height', len_units, adsk.core.ValueInput.createByString('5'))
# Пикер объектов. В фильтре указываем, что можно выбирать плоскости и плоские грани
# Позволить строить редуктор не только в дефолтной ориентации, но и на люой плоскости или грани
plane_select = inputs.addSelectionInput(ID_INPUT_PLANE, 'Input plane', 'select a plane')
plane_select.addSelectionFilter(adsk.core.SelectionCommandInput.PlanarFaces)
plane_select.addSelectionFilter(adsk.core.SelectionCommandInput.ConstructionPlanes)
plane_select.setSelectionLimits(1, 1)
# В конце приаттачим остальные хендлеры к событиям диалога
futil.add_handler(args.command.execute, command_execute, local_handlers=local_handlers)
futil.add_handler(args.command.inputChanged, command_input_changed, local_handlers=local_handlers)
futil.add_handler(args.command.validateInputs, command_validate_input, local_handlers=local_handlers)
futil.add_handler(args.command.destroy, command_destroy, local_handlers=local_handlers)
Если теперь запустить аддон и нажать на кнопку получим вот такой красивый диалог, который пока ничего не делает.

Валидацию входных значений в рамках статьи опустим - она и так выходит не короткая. Так, что следующие это....
Шаг третий. Получаем значения параметров
Для начала, чтоб не кидаться куче отдельных параметров создадим небольшую ДТО для параметров:
class RollerWaveDriveParams:
RESOLUTION = 8
ECCENTRICITY = 0.2
def __init__(self, roller_diameter: float, rollers_number: int, use_balls: bool, roller_height: float,
use_minimal_diameter: bool, cycloid_diameter: float, shaft_diameter: float, roller_tolerance: float,
body_diameter: float, bearing_outer_diameter: float, bearing_inner_diameter: float,
bearing_height: float):
self.roller_diameter = roller_diameter
self.roller_number = rollers_number
self.use_balls = use_balls
self._roller_height = roller_height
self.use_minimal_diameter = use_minimal_diameter
self.cycloid_diameter = cycloid_diameter
self.shaft_diameter = shaft_diameter
self.roller_tolerance = roller_tolerance
self._body_diameter = body_diameter
self.bearing_outer_diameter = bearing_outer_diameter
self.bearing_inner_diameter = bearing_inner_diameter
self.bearing_height = bearing_height
а во вторую очередь добавим фенкцию, которая будет заполнять эту DTO на основе полей диалога
def get_params_from_inputs(inputs: adsk.core.CommandInputs) -> RollerWaveDriveParams:
#получаем поля ввода по их идентификаторам
roller_diameter_input: adsk.core.ValueCommandInput = inputs.itemById(ID_ROLLER_DIAMETER)
rollers_number_input: adsk.core.IntegerSpinnerCommandInput = inputs.itemById(ID_ROLLERS_NUMBER)
use_balls_input: adsk.core.BoolValueCommandInput = inputs.itemById(ID_USE_BALLS)
roller_height_input: adsk.core.ValueCommandInput = inputs.itemById(ID_ROLLER_HEIGHT)
use_minimal_diameter_input: adsk.core.BoolValueCommandInput = inputs.itemById(ID_USE_MINIMAL_DIAMETER)
cycloid_diameter_input: adsk.core.ValueCommandInput = inputs.itemById(ID_CYCLOID_DIAMETER)
shaft_diameter_input: adsk.core.ValueCommandInput = inputs.itemById(ID_INPUT_SHAFT_DIAMETER)
rollers_tolerance_input: adsk.core.ValueCommandInput = inputs.itemById(ID_ROLLER_TOLERANCE)
bearing_outer_diameter_input: adsk.core.ValueCommandInput = inputs.itemById(ID_BEARING_OUTER_DIAMETER)
bearing_inner_diameter_input: adsk.core.ValueCommandInput = inputs.itemById(ID_BEARING_INNER_DIAMETER)
bearing_height_input: adsk.core.ValueCommandInput = inputs.itemById(ID_BEARING_HEIGHT)
body_diameter_input: adsk.core.ValueCommandInput = inputs.itemById(ID_BODY_DIAMETER)
#И передаем их значения в конструктор
return RollerWaveDriveParams(
roller_diameter_input.value,
rollers_number_input.value,
use_balls_input.value,
roller_height_input.value,
use_minimal_diameter_input.value,
cycloid_diameter_input.value,
shaft_diameter_input.value,
rollers_tolerance_input.value,
body_diameter_input.value,
bearing_outer_diameter_input.value,
bearing_inner_diameter_input.value,
bearing_height_input.value
)
Шаг четвертый. Рисуем редуктор
Переходим к самому главному и интересному - будем рисовать, собственно, передачу. Идем в функцию command_execute, и добавляем в нее:
def command_execute(args: adsk.core.CommandEventArgs):
#Получаем ссылку на контролы диалога
inputs = args.command.commandInputs
#и на корневой компонент дизайна
root = design.rootComponent
#При помощи фенкции из предыдущего шага получаем параметры передачи
params = get_params_from_inputs(inputs)
#И получаем плоскость для построения редуктора
plane_input: adsk.core.SelectionCommandInput = inputs.itemById(ID_INPUT_PLANE)
plane: ConstructionPlane = plane_input.selection(0).entity
#Создадим новый компонент для редуктора. Некоторые расширения строят прямо в корневом
#но мне так не удобно.
component = root.occurrences.addNewComponent(adsk.core.Matrix3D.create()).component
#Назовем его как-нибудь
component.name = 'RollerWaveDrive-1-to-{}'.format(params.roller_number)
#Сохраним размер таймлайна
start_index = design.timeline.count - 1
#Нарисуем внешнюю циклоиду редуктора
draw_gear(params, component, plane)
#Нарисуем сепаратор
draw_separator(params, component, plane)
#Нарисуем эксцентрик
draw_cam(params, component, plane)
#И, если у нас стоит в параметрах "использовать шарики"
if params.use_balls:
#То нарисуем шарики
draw_balls(params, component, plane)
else:
#А иначе ролики
draw_rollers(params, component, plane)
#Все что мы нарисовали свернем на таймлайне в группу, чтоб не захламлять его
design.timeline.timelineGroups.add(start_index, design.timeline.count - 1)
Теперь перейдем к реализации функций рисования частей редуктора. Начнем с самого сложного - циклоиды.
def draw_gear(params: RollerWaveDriveParams, component: Component, plane: ConstructionPlane):
#Число впадин на ободе
num_dimples = params.roller_number + 1
#Радиус шарика
ball_radius = params.roller_diameter / 2
eccentricity = params.eccentricity
#Создадим новый скетч на выбранной плоскости
profile_sketch = component.sketches.add(plane)
profile_sketch.name = 'Wheel'
#И массив точек для сплайна циклоиды
points = adsk.core.ObjectCollection.create()
for i in range(params.resolution):
theta = math.pi * 2.0 * i / params.resolution
S = math.sqrt(
(ball_radius + params.cam_radius) ** 2 - math.pow(eccentricity * math.sin(num_dimples * theta), 2))
l = eccentricity * math.cos(num_dimples * theta) + S
xi = math.atan2(eccentricity * num_dimples * math.sin(num_dimples * theta), S)
x = l * math.sin(theta) + ball_radius * math.sin(theta + xi)
y = l * math.cos(theta) + ball_radius * math.cos(theta + xi)
#Добавляем новые точки циклоиды в коллекцию
point = adsk.core.Point3D.create(x, y, 0)
points.add(point)
#Добавим перую точку, как последнюю, чтобы кривая вышла замкнутой
points.add(points[0])
#Создадим сплайн из точек и сделаем его замкнутым
profile_spline = profile_sketch.sketchCurves.sketchFittedSplines.add(points)
profile_spline.isClosed = True
#Нарисуем окружность внешней части колеса
profile_sketch.sketchCurves.sketchCircles.addByCenterRadius(adsk.core.Point3D.create(0, 0, 0),
params.body_diameter)
#берем фичи выдавливания
extrudes = component.features.extrudeFeatures
#взьмем из нашего скетча профиль для выдавливания
prof = profile_sketch.profiles.item(0)
#расстояние выдавливания
distance = adsk.core.ValueInput.createByReal(get_extrusion_height(params))
#и выдавливаем профиль. Получаем сплошной объект - внешнюю часть редуктора
disk_extrude = extrudes.addSimple(prof, distance, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
disk_extrude.bodies.item(0).name = "CycloidWheel"
Небольшая проблема кроется в получении профиля для операций выдавливания/вращения - я так и не смог понять по каком принципу они нумеруются, приходится пользоваться методом проб и ошибок. Благо тут всего 2 варианта. Функция получения высоты выдавливания тривиальна - берем высоту шарика/ролика и добавляем пару миллиметров "про запас"
def get_extrusion_height(params: RollerWaveDriveParams) -> float:
return params.roller_height + 2 * params.roller_tolerance + 0.2
Теперь на очереди сепаратор:
def draw_separator(params: RollerWaveDriveParams, component: Component, plane: ConstructionPlane):
#Снова создадим скетч
sketch = component.sketches.add(plane)
sketch.name = 'Separator'
#И нарисуем 2 круга - внешнюю и внутренюю окружности сепаратора
sketch.sketchCurves.sketchCircles.addByCenterRadius(adsk.core.Point3D.create(0, 0, 0),
params.separator_inner_radius)
sketch.sketchCurves.sketchCircles.addByCenterRadius(adsk.core.Point3D.create(0, 0, 0),
params.separator_outer_radius)
#Выдавим это как и в предыдущий раз
extrudes = component.features.extrudeFeatures
prof = sketch.profiles.item(1)
distance = adsk.core.ValueInput.createByReal(get_extrusion_height(params))
separator_extrude = extrudes.addSimple(prof, distance, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
#И сохрним тело сепаратора, присвоим ему имя
separator_body = separator_extrude.bodies.item(0)
separator_body.name = "Separator"
#Построим ось вращения сепаратора
axis = create_axis_from_cylindrical_body(component, separator_body)
#Проделаем отверстие под шарик или ролик
hole_feature = create_round_hole(params, component, plane) if params.use_balls else create_square_hole(params, component, plane)
#Размножим отверстие по кругу
create_circular_pattern(axis, hole_feature, params.roller_number)
Ось вращения получается как construction axis по цилиндрической поверхности, сепаратор у нас как раз их имеет аж 2 штуки.
def create_axis_from_cylindrical_body(component: Component, separator_body: BRepBody) -> ConstructionAxis:
axis_input = component.constructionAxes.createInput()
axis_input.setByCircularFace(find_cylindrical_face(separator_body))
axis = component.constructionAxes.add(axis_input)
return axis
#Находим циллиндрический фейс
def find_cylindrical_face(body: BRepBody) -> BRepFace:
for face in body.faces:
geom = face.geometry
if geom.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:
return face
Отверстие под ролик (рассмотрим ролик, шарик делается плюс-минус аналогично, можно посмотреть в коде)
def create_square_hole(params: RollerWaveDriveParams, component: Component, plane: ConstructionPlane) -> Feature:
extrudes = component.features.extrudeFeatures
planes = component.constructionPlanes
#создаем параметры для новой плоскости построения
plane_input = planes.createInput()
#Делаем новую плоскость как смещенную изначальную, на толщину "крышки" сепаратора, 1мм
plane_input.setByOffset(plane, adsk.core.ValueInput.createByReal(0.1))
holes_plane = planes.add(plane_input)
#создаем на этой плоскости новый скетч
holes_sketch = component.sketches.add(holes_plane)
holes_sketch.name = 'RollerHole'
#Нарисуем прямоуголник, размером с ролик + запас
holes_sketch.sketchCurves.sketchLines.addCenterPointRectangle(
adsk.core.Point3D.create(0, params.separator_middle_radius, 0),
adsk.core.Point3D.create(params.roller_diameter / 2 + params.roller_tolerance,
params.separator_middle_radius + params.separator_thickness, 0),
)
prof = holes_sketch.profiles.item(0)
distance = adsk.core.ValueInput.createByReal(params.roller_height + 2 * params.roller_tolerance)
#Выдавим профиль прямоуголника, но не как новое тело, а как "вырез"
hole_extrude = extrudes.addSimple(prof, distance, adsk.fusion.FeatureOperations.CutFeatureOperation)
return hole_extrude
И надо это отверстие размножить по кругу (circular pattern) вокруг полученной оси
def create_circular_pattern(axis: ConstructionAxis, feature: Feature, num_copies: int):
#Создаем колелкцию для размножаемых фич
collection = adsk.core.ObjectCollection.create()
collection.add(feature)
#Указываем параметры размножения - количество копий и угол.
pattern_features = feature.parentComponent.features.circularPatternFeatures
pattern_input = pattern_features.createInput(collection, axis)
pattern_input.quantity = adsk.core.ValueInput.createByReal(num_copies)
#можно указать как adsk.core.ValueInput.createByReal(2 * pi)
pattern_input.totalAngle = adsk.core.ValueInput.createByString('360 deg')
pattern_input.isSymmetric = False
#строим круговой паттерн
pattern_features.add(pattern_input)
Реализацию отрисовки эксцентрика и шариков/роликов оставим за рамками статьи - они не содержат ничего нового сверх уже рассмотренного выше, ознакомится, при желании, можно в коде на гитхабе.
По итогу у нас получается вот такой красивенький редуктор.

В итоге, конечно, этот тип редуктора оказался хуже циклоидального: изначально он меня подкупил "круглым" выходным звеном, не требующем пальцевой муфты или муфты Олдема, как циклоидальный. Но на этом его плюсы, пожалуй и заканчиваются: при работе издает много шума, особенно на высоких оборотах, и имеет явно более низкий КПД.
Комментарии (4)
sdy
18.06.2025 10:28Хороший был тул. Бесплатная лицуха на хобби перестала работать скоро как год. Активация не проходит, там блокировка постоянная
Вроде бы ничего сложного и долгого нарисовать такой редуктор руками, максимум полчаса, если все посчитано было..Тут скорее всего сложность не в рисовании, а в самом принципе работы.этого редуктора
Было бы здорово на чем то более простом показать механизм добавления аддонов
Oncenweek Автор
18.06.2025 10:28Активация не проходит
Продлял в начале этого года, вроде получилось. Разумеется пришлось обходить геоблок, но он вроде не очень плотный был
Вроде бы ничего сложного и долгого нарисовать такой редуктор руками
Глобально да, но если экспериментируешь с ними хочется ускорить процесс
на чем то более простом
Хотелось найти пример где были бы все основные элементы для построений, которые могу понадобится.
sdy
18.06.2025 10:28Я тоже пытался продлить прошлым летом, увы, предложили только платить, хотя до этого пару раз продлевал успешно. Где то лет 7-8 сидел на Фьюжн и прям было очень печально расстаться. Как только понял, что все хана, так сразу все экспортнул в степ и выгрузил через аддон специальный
positroid
Интересный механизм, не хватает только демонстрации работы:
Демо
Взял отсюда