Пишем аддон для Fusion 360

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/919478/