Привет, друзья! Сегодня я расскажу вам, как своими руками написать небольшое расширение для известной САПР 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)

Реализацию отрисовки эксцентрика и шариков/роликов оставим за рамками статьи - они не содержат ничего нового сверх уже рассмотренного выше, ознакомится, при желании, можно в коде на гитхабе.

По итогу у нас получается вот такой красивенький редуктор.

редуктор в интрефейсе Fusion 360
редуктор в интрефейсе Fusion 360

В итоге, конечно, этот тип редуктора оказался хуже циклоидального: изначально он меня подкупил "круглым" выходным звеном, не требующем пальцевой муфты или муфты Олдема, как циклоидальный. Но на этом его плюсы, пожалуй и заканчиваются: при работе издает много шума, особенно на высоких оборотах, и имеет явно более низкий КПД.

Комментарии (4)


  1. positroid
    18.06.2025 10:28

    Интересный механизм, не хватает только демонстрации работы:

    Демо

    Взял отсюда


  1. sdy
    18.06.2025 10:28

    Хороший был тул. Бесплатная лицуха на хобби перестала работать скоро как год. Активация не проходит, там блокировка постоянная

    Вроде бы ничего сложного и долгого нарисовать такой редуктор руками, максимум полчаса, если все посчитано было..Тут скорее всего сложность не в рисовании, а в самом принципе работы.этого редуктора

    Было бы здорово на чем то более простом показать механизм добавления аддонов


    1. Oncenweek Автор
      18.06.2025 10:28

      Активация не проходит

      Продлял в начале этого года, вроде получилось. Разумеется пришлось обходить геоблок, но он вроде не очень плотный был

      Вроде бы ничего сложного и долгого нарисовать такой редуктор руками

      Глобально да, но если экспериментируешь с ними хочется ускорить процесс

      на чем то более простом

      Хотелось найти пример где были бы все основные элементы для построений, которые могу понадобится.


      1. sdy
        18.06.2025 10:28

        Я тоже пытался продлить прошлым летом, увы, предложили только платить, хотя до этого пару раз продлевал успешно. Где то лет 7-8 сидел на Фьюжн и прям было очень печально расстаться. Как только понял, что все хана, так сразу все экспортнул в степ и выгрузил через аддон специальный