├── .gitignore ├── BoxJoint.manifest ├── BoxJoint.py ├── LICENSE ├── README.md ├── Resources └── BoxJoint │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 16x16.png │ ├── 16x16@2x.png │ ├── 32x32.png │ ├── 32x32@2x.png │ ├── 64x64.png │ └── 64x64@2x.png ├── fusion_base_combines.py ├── fusion_box_joint.py ├── fusion_brep_util.py ├── fusion_cf_addin.py ├── fusion_util.py └── privacy_policy.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Fusion 360 generates these files when the Add-In is edited or debugged from within Fusion 360. 2 | /.env 3 | /.vscode/launch.json 4 | /.vscode/settings.json 5 | -------------------------------------------------------------------------------- /BoxJoint.manifest: -------------------------------------------------------------------------------- 1 | { 2 | "autodeskProduct": "Fusion360", 3 | "type": "addin", 4 | "id": "629a8477-0c8d-4fb1-80f5-ea32dddbc134", 5 | "author": "Mark Suska", 6 | "description": { 7 | "": "CNC friendly box/finger joints." 8 | }, 9 | "version": "1.0.3", 10 | "runOnStartup": true, 11 | "supportedOS": "windows|mac" 12 | } 13 | -------------------------------------------------------------------------------- /BoxJoint.py: -------------------------------------------------------------------------------- 1 | from .fusion_box_joint import BoxJointAddIn 2 | from .fusion_util import log, handleException 3 | 4 | 5 | log(f'Loading Fusion Add-In {repr(__file__)} ...') 6 | 7 | 8 | thisAddIn: BoxJointAddIn = None 9 | 10 | 11 | def run(context): 12 | try: 13 | log(f'Starting Fusion Add-In {repr(__file__)} ...') 14 | global thisAddIn 15 | thisAddIn = BoxJointAddIn() 16 | except: 17 | handleException() 18 | 19 | 20 | def stop(context): 21 | try: 22 | log(f'Stopping Fusion Add-In {repr(__file__)} ...') 23 | global thisAddIn 24 | del thisAddIn 25 | except: 26 | handleException() 27 | 28 | 29 | log(f'Finished loading Fusion Add-In {repr(__file__)}') 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mark Suska 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Box Joint 2 | 3 | This is an [Autodesk Fusion 360](https://www.autodesk.com/products/fusion-360/overview) [Add-In](https://apps.autodesk.com/FUSION/en/Detail/Index?id=3675336968156301217) for creating CNC friendly box/finger joints. 4 | 5 | ## Editable Feature 6 | 7 | The Box Joint appears as a single Feature in the Timeline. 8 | 9 | * The Box Joint Feature can be edited after creation just like any other built-in feature. 10 | * The joint will be automatically recomputed if any of the dependent bodies earlier in the Timeline are changed. 11 | 12 | ## CNC Friendly 13 | 14 | The joint has been designed to be cuttable on a CNC machine. 15 | 16 | * Can be cut flat from a single side on an 3-axis CNC machine. 17 | * No voids are visible even though inside corners are rounded. 18 | 19 | ## Screen Shots 20 | 21 | ![BoxJoint1](https://github.com/EvilHacker/BoxJoint/assets/6175398/30556d0e-5731-4d13-a690-e96b2998a220) 22 | 23 | ![BoxJoint2](https://github.com/EvilHacker/BoxJoint/assets/6175398/3521f3d9-5d79-48d2-96bd-85b8eeb5541f) 24 | 25 | ![BoxJoint3](https://github.com/EvilHacker/BoxJoint/assets/6175398/80d32152-d66f-4f07-979a-e63abc7cafd5) 26 | 27 | ![BoxJoint4](https://github.com/EvilHacker/BoxJoint/assets/6175398/05f251b6-883c-4303-a9a6-cdd9a2b7ca23) 28 | 29 | ## Installation 30 | 31 | This Add-In can be installed by either: 32 | 33 | * Downloading and running the installer from the [Fusion 360 App Store](https://apps.autodesk.com/FUSION/en/Detail/Index?id=3675336968156301217). 34 | * Using [GitHubToFusion360](https://apps.autodesk.com/FUSION/en/Detail/Index?id=789800822168335025). Paste the github link `https://github.com/EvilHacker/BoxJoint` into the [GitHubToFusion360](https://apps.autodesk.com/FUSION/en/Detail/Index?id=789800822168335025) Add-In. 35 | * Downloading the Add-In [zip file](https://codeload.github.com/EvilHacker/BoxJoint/zip/refs/heads/main) from github and extracting it to: 36 | * `~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/AddIns` (Mac OS) 37 | * `%appdata%\Autodesk\Autodesk Fusion 360\API\AddIns` (Windows) 38 | 39 | ## Usage 40 | 41 | The Add-In can be found in the `DESIGN` Workspace, `SOLID` tab, `MODIFY` panel. 42 | 43 | Start by creating a model with simple [butt joints](https://en.wikipedia.org/wiki/Butt_joint). Bodies to be joined should contact each other at a planar surface. Multiple bodies can be butted up against each other at any angle to form a box-like structure. 44 | 45 | To create a Box Joint, open the Box Joint Add-In and select the outside faces of the bodies to join. A Box Joint will be created between every pair of bodies that butt up against each other. 46 | 47 | A Box Joint feature can be modified at any time by selecting it in the timeline and choosing "Edit Feature". 48 | 49 | ## Example Designs 50 | 51 | * [Box ![Box](https://github.com/EvilHacker/BoxJoint/assets/6175398/9ea6beee-c7d9-4b69-b2e3-c20a67efa259) 52 | ](https://a360.co/3RRLTNm) 53 | * [Flowerpot ![Flowerpot](https://github.com/EvilHacker/BoxJoint/assets/6175398/25d318fb-cdb7-48fb-8cc1-2877b58a4955) 54 | ](https://a360.co/3vydzPO) 55 | -------------------------------------------------------------------------------- /Resources/BoxJoint/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/128x128.png -------------------------------------------------------------------------------- /Resources/BoxJoint/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/128x128@2x.png -------------------------------------------------------------------------------- /Resources/BoxJoint/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/16x16.png -------------------------------------------------------------------------------- /Resources/BoxJoint/16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/16x16@2x.png -------------------------------------------------------------------------------- /Resources/BoxJoint/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/32x32.png -------------------------------------------------------------------------------- /Resources/BoxJoint/32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/32x32@2x.png -------------------------------------------------------------------------------- /Resources/BoxJoint/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/64x64.png -------------------------------------------------------------------------------- /Resources/BoxJoint/64x64@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvilHacker/BoxJoint/42ea9b528361f3171f014a41d9e396093cd38442/Resources/BoxJoint/64x64@2x.png -------------------------------------------------------------------------------- /fusion_base_combines.py: -------------------------------------------------------------------------------- 1 | import adsk.core 2 | import adsk.fusion 3 | from dataclasses import dataclass, field 4 | 5 | from .fusion_util import * 6 | from .fusion_brep_util import * 7 | 8 | 9 | @dataclass 10 | class _BooleanOperations: 11 | targetBody: adsk.fusion.BRepBody 12 | operations: list[BooleanOperation] = field(default_factory=list) 13 | 14 | 15 | class BaseCombines: 16 | def __init__(self): 17 | self._opsByTargetBodyId: dict[str, _BooleanOperations] = {} 18 | 19 | def add(self, booleanOperation: BooleanOperation): 20 | self.addTargetBody(booleanOperation.targetBody).operations.append(booleanOperation) 21 | 22 | def addTargetBody(self, targetBody: adsk.fusion.BRepBody): 23 | return self._opsByTargetBodyId.setdefault( 24 | targetBody.revisionId, _BooleanOperations(targetBody)) 25 | 26 | def createOrUpdate(self, 27 | existingFeatures: list[adsk.fusion.Feature], 28 | allowFeatureCreationAndDeletion 29 | ) -> list[adsk.fusion.Feature]: 30 | orderedOpsByTargetBodyId: dict[str, _BooleanOperations] = {} 31 | existingFeaturesByTargetBodyId: dict[str, tuple[ 32 | adsk.fusion.BaseFeature, # base feature containing the tool body 33 | adsk.fusion.CombineFeature, # join feature 34 | adsk.fusion.CombineFeature # intersect feature 35 | ]] = {} 36 | resultingFeatures = [] # existing plus any new features 37 | 38 | # Examine existing features in triples. 39 | for baseFeature, joinFeature, intersectFeature in zip(* [iter(existingFeatures)] * 3): 40 | targetBody = joinFeature.targetBody 41 | targetBodyId = targetBody.revisionId 42 | ops = self._opsByTargetBodyId.get(targetBodyId) 43 | if not ops and allowFeatureCreationAndDeletion: 44 | # Delete the features associated with the unknown target body. 45 | intersectFeature.deleteMe() 46 | joinFeature.deleteMe() 47 | baseFeature.deleteMe() 48 | else: 49 | # Add operations to this target body. 50 | orderedOpsByTargetBodyId[targetBodyId] = ops or _BooleanOperations(targetBody) 51 | 52 | # Add the existing features related this target body. 53 | existingFeaturesByTargetBodyId.setdefault(targetBodyId, ( 54 | baseFeature, joinFeature, intersectFeature)) 55 | 56 | if allowFeatureCreationAndDeletion: 57 | # Add operations to any target bodies that don't have existing features. 58 | for targetBodyId, ops in self._opsByTargetBodyId.items(): 59 | orderedOpsByTargetBodyId[targetBodyId] = ops 60 | 61 | design: adsk.fusion.Design = adsk.core.Application.get().activeProduct 62 | component = design.activeComponent 63 | baseFeatures = component.features.baseFeatures 64 | combineFeatures = component.features.combineFeatures 65 | 66 | # Create or update the features for each target body. 67 | for targetBodyId, ops in orderedOpsByTargetBodyId.items(): 68 | # Get existing features (if any). 69 | baseFeature, joinFeature, intersectFeature = existingFeaturesByTargetBodyId.get( 70 | targetBodyId, (None, None, None)) 71 | 72 | # Modify a copy of the original target body. 73 | targetBody = ops.targetBody 74 | modifiedTargetBody = tempBrepMgr.copy(targetBody) 75 | for operation in ops.operations: 76 | tempBrepMgr.booleanOperation( 77 | modifiedTargetBody, operation.toolBody, operation.operation) 78 | 79 | # Create or update the BaseFeature. 80 | if baseFeature: 81 | # Update the existing BaseFeature. 82 | baseFeature.timelineObject.rollTo(rollBefore = False) 83 | baseFeature.startEdit() 84 | baseFeature.updateBody(baseFeature.bodies[0], modifiedTargetBody) 85 | baseFeature.finishEdit() 86 | else: 87 | # Create a new BaseFeature. 88 | initialDummyBody = aContainingBody(modifiedTargetBody) 89 | if resultingFeatures: 90 | resultingFeatures[-1].timelineObject.rollTo(rollBefore = False) 91 | baseFeature = baseFeatures.add() 92 | baseFeature.startEdit() 93 | component.bRepBodies.add(initialDummyBody, baseFeature) 94 | baseFeature.updateBody(baseFeature.bodies[0], modifiedTargetBody) 95 | baseFeature.finishEdit() 96 | baseFeature.sourceBodies[0].name = targetBody.name + ' *' 97 | resultingFeatures.append(baseFeature) 98 | 99 | # Create the CombineFeatures (if they don't exist yet). 100 | tools = newObjectCollection(baseFeature.bodies) 101 | for combineFeature, operation, isKeepToolBodies in [ 102 | (joinFeature, adsk.fusion.FeatureOperations.JoinFeatureOperation, True), 103 | (intersectFeature, adsk.fusion.FeatureOperations.IntersectFeatureOperation, False), 104 | ]: 105 | if combineFeature: 106 | # combineFeature.targetBody = targetBody 107 | if combineFeature.targetBody != targetBody: 108 | combineFeature.targetBody = targetBody 109 | else: 110 | # Create a new CombineFeature. 111 | resultingFeatures[-1].timelineObject.rollTo(rollBefore = False) 112 | combineInput = combineFeatures.createInput(targetBody, tools) 113 | combineInput.operation = operation 114 | combineInput.isKeepToolBodies = isKeepToolBodies 115 | combineFeature = combineFeatures.add(combineInput) 116 | resultingFeatures.append(combineFeature) 117 | 118 | return resultingFeatures 119 | -------------------------------------------------------------------------------- /fusion_box_joint.py: -------------------------------------------------------------------------------- 1 | import adsk.core 2 | import adsk.fusion 3 | from dataclasses import dataclass, field 4 | import itertools 5 | import math 6 | import re 7 | 8 | from .fusion_brep_util import * 9 | from .fusion_base_combines import BaseCombines 10 | from .fusion_cf_addin import FusionCustomFeatureAddIn 11 | from .fusion_util import * 12 | 13 | 14 | @dataclass 15 | class BoxJointParameters: 16 | """ 17 | All parameter that define a specific box joint. 18 | 19 | Note that assignment of default values is deferred to instance creation time 20 | (using `default_factory`) rather than using static values because `Parameter` units 21 | will vary within the context of the current document and user preferences. 22 | """ 23 | faces: list[EntityRef] = field(default_factory=list) 24 | minFingers: Parameter = field( 25 | default_factory=lambda: Parameter(3)) 26 | maxFingers: Parameter = field( 27 | default_factory=lambda: Parameter(33)) 28 | minFingerWidth: Parameter = field( 29 | default_factory=lambda: Parameter.length(centimeters=cmOrIn(2.5, 1))) # 2.5cm or 1" 30 | maxFingerWidth: Parameter = field( 31 | default_factory=lambda: Parameter.length(centimeters=cmOrIn(15, 6))) # 15cm or 6" 32 | fingerRatio: Parameter = field( 33 | default_factory=lambda: Parameter(0.5)) 34 | margin: Parameter = field( 35 | default_factory=lambda: Parameter.length(centimeters=0)) 36 | bitDiameter: Parameter = field( 37 | default_factory=lambda: Parameter.length(centimeters=0.635)) # 1/4" 38 | 39 | 40 | class BoxJointAddIn(FusionCustomFeatureAddIn): 41 | """ 42 | The Box Joint Fusion Add-In. 43 | """ 44 | def __init__(self): 45 | super().__init__( 46 | baseCommandId='Suska_BoxJoint', 47 | name='Box Joint', 48 | createTooltip='Create box/finger joints between two or more bodies.', 49 | editTooltip='Edit box/finger joints between bodies.', 50 | resourceFolder='Resources/BoxJoint', 51 | toolbarControls=[ 52 | { 53 | 'workspace': 'FusionSolidEnvironment', 54 | 'panel': 'SolidScriptsAddinsPanel', 55 | }, 56 | { 57 | 'workspace': 'FusionSolidEnvironment', 58 | 'panel': 'SolidModifyPanel', 59 | 'afterControl': 'FusionCombineCommand', 60 | 'promote': True 61 | }, 62 | ], 63 | ) 64 | 65 | def createInputs(self, command: adsk.core.Command, params: BoxJointParameters): 66 | inputs = command.commandInputs 67 | 68 | # Create the selection input to select the planar faces on solid bodies. 69 | # Note: Selections cannot be pre-populated now. 70 | facesSelectInput = inputs.addSelectionInput( 71 | 'faces', 72 | 'Faces', 73 | 'Select outside faces of bodies to join.') 74 | facesSelectInput.addSelectionFilter('SolidFaces') 75 | facesSelectInput.tooltip = 'Select outside faces of bodies to join.' 76 | facesSelectInput.setSelectionLimits(2) # At least two faces needed. 77 | 78 | input = inputs.addIntegerSpinnerCommandInput( 79 | 'minFingers', 'Min Fingers', 80 | min=3, max=99, spinStep=2, 81 | initialValue=int(clamp(params.minFingers.value, 3, 99))) 82 | input.expression = params.minFingers.expression 83 | 84 | input = inputs.addIntegerSpinnerCommandInput( 85 | 'maxFingers', 'Max Fingers', 86 | min=3, max=99, spinStep=2, 87 | initialValue=int(clamp(params.maxFingers.value, 3, 99))) 88 | input.expression = params.maxFingers.expression 89 | 90 | input = inputs.addValueInput( 91 | 'minFingerWidth', 'Min Finger Width', 92 | unitType=params.minFingerWidth.units, 93 | initialValue=params.minFingerWidth.valueInput) 94 | input.minimumValue = 0 95 | input.isMinimumInclusive = False 96 | input.isMinimumLimited = True 97 | 98 | input = inputs.addValueInput( 99 | 'maxFingerWidth', 'Max Finger Width', 100 | unitType=params.maxFingerWidth.units, 101 | initialValue=params.maxFingerWidth.valueInput) 102 | input.minimumValue = 0 103 | input.isMinimumInclusive = False 104 | input.isMinimumLimited = True 105 | 106 | input = inputs.addFloatSpinnerCommandInput( 107 | 'fingerRatio', 'Finger Ratio', 108 | unitType=Parameter.UNITLESS, min=0.01, max=0.99, spinStep=0.1, 109 | initialValue=clamp(params.fingerRatio.value, 0.01, 0.99)) 110 | input.expression = params.fingerRatio.expression 111 | 112 | input = inputs.addValueInput( 113 | 'margin', 'Margin', 114 | unitType=params.margin.units, 115 | initialValue=params.margin.valueInput) 116 | input.minimumValue = 0 117 | input.isMinimumInclusive = True 118 | input.isMinimumLimited = True 119 | 120 | input = inputs.addValueInput( 121 | 'bitDiameter', 'Tool Diameter', 122 | unitType=params.bitDiameter.units, 123 | initialValue=params.bitDiameter.valueInput) 124 | input.minimumValue = 0 125 | input.isMinimumInclusive = True 126 | input.isMinimumLimited = True 127 | 128 | # For error message output. 129 | inputs.addTextBoxCommandInput('error', '', '', numRows=1, isReadOnly=True) 130 | 131 | def canSelect(self, entity, input: adsk.core.SelectionCommandInput) -> bool: 132 | if input.id == 'faces': 133 | # Must be a planar face on a solid body. 134 | if entity.geometry.surfaceType != adsk.core.SurfaceTypes.PlaneSurfaceType: 135 | return False 136 | if not entity.body.isSolid: 137 | return False 138 | return True 139 | 140 | def areInputsValid(self, commandInputs: adsk.core.CommandInputs) -> bool: 141 | errorMessage = None 142 | try: 143 | for input in commandInputs: 144 | if hasattr(input, 'isValidExpression') and not input.isValidExpression: 145 | raise UserInputError(input, 'is invalid') 146 | 147 | for inputId in 'minFingers', 'maxFingers': 148 | input = commandInputs.itemById(inputId) 149 | value = input.value 150 | if math.floor(value) != math.ceil(value): 151 | raise UserInputError(input, 'must be an integer') 152 | if int(value) & 1 == 0: 153 | raise UserInputError(input, 'must be odd') 154 | except UserInputError as error: 155 | errorMessage = f'{error.input.name} {error.message}.' 156 | 157 | # Set error message only if it has changed. 158 | errorBox: adsk.core.TextBoxCommandInput = commandInputs.itemById('error') 159 | if errorMessage: 160 | if errorBox.text != withoutHtml(errorMessage): 161 | log(f'User input error: {withoutHtml(errorMessage)}') 162 | errorBox.formattedText = errorMessage 163 | elif errorBox.text: 164 | log(f'User input error cleared') 165 | errorBox.formattedText = '' 166 | 167 | return errorMessage is None 168 | 169 | def defaultParams(self) -> BoxJointParameters: 170 | return BoxJointParameters() 171 | 172 | def paramsToInputs(self, params: BoxJointParameters, commandInputs: adsk.core.CommandInputs): 173 | facesInput: adsk.core.SelectionCommandInput = commandInputs.itemById('faces') 174 | facesInput.clearSelection() 175 | for face in params.faces: 176 | facesInput.addSelection(face.entity) 177 | 178 | commandInputs.itemById('minFingers').expression = params.minFingers.expression 179 | commandInputs.itemById('maxFingers').expression = params.maxFingers.expression 180 | commandInputs.itemById('minFingerWidth').expression = params.minFingerWidth.expression 181 | commandInputs.itemById('maxFingerWidth').expression = params.maxFingerWidth.expression 182 | commandInputs.itemById('fingerRatio').expression = params.fingerRatio.expression 183 | commandInputs.itemById('margin').expression = params.margin.expression 184 | commandInputs.itemById('bitDiameter').expression = params.bitDiameter.expression 185 | 186 | def inputsToParams(self, commandInputs: adsk.core.CommandInputs) -> BoxJointParameters: 187 | facesInput: adsk.core.SelectionCommandInput = commandInputs.itemById('faces') 188 | faces = [] 189 | for i in range(0, facesInput.selectionCount): 190 | faces.append(EntityRef(facesInput.selection(i).entity)) 191 | 192 | return BoxJointParameters( 193 | faces=faces, 194 | minFingers=Parameter(commandInputs.itemById('minFingers')), 195 | maxFingers=Parameter(commandInputs.itemById('maxFingers')), 196 | minFingerWidth=Parameter(commandInputs.itemById('minFingerWidth')), 197 | maxFingerWidth=Parameter(commandInputs.itemById('maxFingerWidth')), 198 | fingerRatio=Parameter(commandInputs.itemById('fingerRatio')), 199 | margin=Parameter(commandInputs.itemById('margin')), 200 | bitDiameter=Parameter(commandInputs.itemById('bitDiameter')), 201 | ) 202 | 203 | def customFeatureToParams(self, feature: adsk.fusion.CustomFeature) -> BoxJointParameters: 204 | customNamedValues = feature.customNamedValues 205 | parameters = feature.parameters 206 | 207 | faces = [EntityRef(token) for token in 208 | customNamedValues.value('faces').split()] 209 | 210 | return BoxJointParameters( 211 | faces=faces, 212 | minFingers=Parameter(parameters.itemById('minFingers')), 213 | maxFingers=Parameter(parameters.itemById('maxFingers')), 214 | minFingerWidth=Parameter(parameters.itemById('minFingerWidth')), 215 | maxFingerWidth=Parameter(parameters.itemById('maxFingerWidth')), 216 | fingerRatio=Parameter(parameters.itemById('fingerRatio') or 0.5), 217 | margin=Parameter(parameters.itemById('margin') or 0), 218 | bitDiameter=Parameter(parameters.itemById('bitDiameter') or 0), 219 | ) 220 | 221 | def getCustomParameters(self, params: BoxJointParameters) -> dict[str, Parameter]: 222 | return { 223 | 'minFingers': params.minFingers, 224 | 'maxFingers': params.maxFingers, 225 | 'minFingerWidth': params.minFingerWidth, 226 | 'maxFingerWidth': params.maxFingerWidth, 227 | 'fingerRatio': params.fingerRatio, 228 | 'margin': params.margin, 229 | 'bitDiameter': params.bitDiameter, 230 | } 231 | 232 | def getCustomParameterDescriptions(self) -> dict[str, str]: 233 | return { 234 | 'minFingers': 'Min Fingers', 235 | 'maxFingers': 'Max Fingers', 236 | 'minFingerWidth': 'Min Finger Width', 237 | 'maxFingerWidth': 'Max Finger Width', 238 | 'fingerRatio': 'Finger Ratio', 239 | 'margin': 'Margin', 240 | 'bitDiameter': 'Tool Diameter', 241 | } 242 | 243 | def getCustomNamedValues(self, params: BoxJointParameters) -> dict[str, str]: 244 | return { 245 | 'faces': ' '.join([face.entityToken for face in params.faces]), 246 | } 247 | 248 | def getDependencies(self, params: BoxJointParameters) -> dict[str, adsk.core.Base]: 249 | bodies = {} 250 | for face in params.faces: 251 | body = face.entity.body 252 | bodies[body.revisionId] = body 253 | return {f'body{i}': body for i, body in enumerate(bodies.values())} 254 | 255 | def createOrUpdateChildFeatures(self, 256 | params: BoxJointParameters, 257 | existingFeatures: list[adsk.fusion.Feature], 258 | allowFeatureCreationAndDeletion: bool 259 | ) -> list[adsk.fusion.Feature]: 260 | baseCombines = computeBoxJoint(params) 261 | features = baseCombines.createOrUpdate( 262 | existingFeatures=existingFeatures, 263 | allowFeatureCreationAndDeletion=allowFeatureCreationAndDeletion) 264 | return features 265 | 266 | 267 | def computeBoxJoint(params: BoxJointParameters) -> BaseCombines: 268 | baseCombines = BaseCombines() 269 | 270 | if len(params.faces) < 2: 271 | return baseCombines 272 | 273 | # Normalize parameter values. 274 | faces = [face.entity for face in params.faces] 275 | bitDiameter = max(params.bitDiameter.value, 0) 276 | bitRadius = bitDiameter / 2 277 | minFingers = max(int(params.minFingers.value), 3) | 1 278 | maxFingers = max(int(params.maxFingers.value) - 1, minFingers) | 1 279 | minFingerWidth = max(params.minFingerWidth.value, bitDiameter, 0.0001) 280 | maxFingerWidth = max(params.maxFingerWidth.value, minFingerWidth) 281 | fingerRatio = clamp(params.fingerRatio.value, 282 | minFingerWidth / (minFingerWidth + maxFingerWidth), 283 | maxFingerWidth / (minFingerWidth + maxFingerWidth)) 284 | rAB = (1 - fingerRatio) / fingerRatio 285 | rBA = fingerRatio / (1 - fingerRatio) 286 | margin = max(params.margin.value, 0) 287 | 288 | # Add all possible target bodies in case there are no operations on some. 289 | for face in faces: 290 | baseCombines.addTargetBody(face.body) 291 | 292 | # Compute the box joint between each pair of faces. 293 | for faceA, faceB, buttingFace in getAllButtingFaces(faces): 294 | bodyA = faceA.body 295 | bodyB = faceB.body 296 | planeA: adsk.core.Plane = faceA.geometry 297 | planeB: adsk.core.Plane = faceB.geometry 298 | faceAInwardNormal = planeA.normal.copy() 299 | faceBInwardNormal = planeB.normal.copy() 300 | 301 | # Normalize the orientation of the joint. 302 | if not faceA.isParamReversed: 303 | faceAInwardNormal.scaleBy(-1) 304 | planeA.normal = faceAInwardNormal 305 | if not faceB.isParamReversed: 306 | faceBInwardNormal.scaleBy(-1) 307 | planeB.normal = faceBInwardNormal 308 | 309 | # Define actual Joint and Nominal coordinate systems. 310 | outerEdge = planeB.intersectWithPlane(planeA) 311 | jointOrigin = outerEdge.origin 312 | jointZAxis = outerEdge.direction 313 | jointYAxis = faceAInwardNormal 314 | jointXAxis = faceAInwardNormal.crossProduct(jointZAxis) 315 | jointToNominal = adsk.core.Matrix3D.create() 316 | jointToNominal.setToAlignCoordinateSystems( 317 | fromOrigin=jointOrigin, 318 | fromXAxis=jointXAxis, 319 | fromYAxis=jointYAxis, 320 | fromZAxis=jointZAxis, 321 | toOrigin=adsk.core.Point3D.create(0, 0, 0), 322 | toXAxis=adsk.core.Vector3D.create(1, 0, 0), 323 | toYAxis=adsk.core.Vector3D.create(0, 1, 0), 324 | toZAxis=adsk.core.Vector3D.create(0, 0, 1)) 325 | nominalToJoint = jointToNominal.copy() 326 | nominalToJoint.invert() 327 | 328 | # Sweep the butting face to define the overlapping region between 329 | # body A and body B where the joint will be created. 330 | sweepLine = adsk.core.InfiniteLine3D.create( 331 | jointOrigin, faceBInwardNormal.crossProduct(jointZAxis)) 332 | pB: adsk.core.Point3D = buttingFace.geometry.intersectWithLine(sweepLine) 333 | sweepVector = pB.vectorTo(jointOrigin) 334 | overlap = createObliquePrism(buttingFace, sweepVector) 335 | tempBrepMgr.booleanOperation(overlap, bodyA, adsk.fusion.BooleanTypes.IntersectionBooleanType) 336 | tempBrepMgr.transform(overlap, jointToNominal) 337 | overlapBox = overlap.boundingBox 338 | minX, minY, minZ = overlapBox.minPoint.asArray() 339 | maxX, maxY, maxZ = overlapBox.maxPoint.asArray() 340 | 341 | # Compute the number of fingers and their widths. 342 | lengthWithMargins = maxZ - minZ 343 | length = lengthWithMargins - 2 * margin 344 | if fingerRatio < 0.5: 345 | fingerAWidth = min(minFingerWidth * rAB, maxFingerWidth) 346 | else: 347 | fingerAWidth = minFingerWidth 348 | fingers = min( 349 | math.floor(2 * (length / fingerAWidth - 1) / (1 + rBA)) | 1, 350 | maxFingers) 351 | if fingers < minFingers: 352 | # Would have too few fingers. 353 | continue 354 | fingerAWidth = length / ((1 + rBA) * math.floor(fingers / 2) + 1) 355 | if fingerAWidth > maxFingerWidth: 356 | fingerAWidth = maxFingerWidth 357 | fingerBWidth = fingerAWidth * rBA 358 | if fingerBWidth > maxFingerWidth: 359 | fingerBWidth = maxFingerWidth 360 | fingerAWidth = maxFingerWidth * rAB 361 | length = fingerAWidth + math.floor(fingers / 2) * (fingerAWidth + fingerBWidth) 362 | margin = (lengthWithMargins - length) / 2 363 | 364 | # Create a transformation matrix that will be reused for all upward translations. 365 | translateUp = adsk.core.Matrix3D.create() 366 | translateUp.setCell(2, 3, fingerBWidth) 367 | 368 | # Create a template for a single B finger. 369 | finger = createSimpleBox( 370 | minX, minY, minZ, 371 | maxX - minX, maxY - minY, fingerBWidth) 372 | fingerACutter = fingerBJoiner = finger 373 | 374 | # Define various reference points and vectors on the finger cross-section. 375 | pO = jointOrigin.copy() 376 | pO.transformBy(jointToNominal) 377 | pO.z = minZ 378 | pB.transformBy(jointToNominal) 379 | pB.z = minZ 380 | vOB = pO.vectorTo(pB) 381 | vOBPerp = adsk.core.Vector3D.create(vOB.y, -vOB.x, 0) 382 | 383 | isAcute = pB.x > pO.x + adsk.core.Application.get().pointTolerance 384 | isObtuse = pB.x < pO.x - adsk.core.Application.get().pointTolerance 385 | 386 | if isObtuse: 387 | # Joint angle is > 90°. 388 | pI = adsk.core.Point3D.create(maxX + vOB.x, maxY, minZ) 389 | pA = adsk.core.Point3D.create(maxX, minY, minZ) 390 | pAe = adsk.core.Point3D.create(max(pI.x, pO.x), minY, minZ) 391 | lOB = adsk.core.InfiniteLine3D.create(pO, vOB) 392 | lBeI = adsk.core.InfiniteLine3D.create(pI, vOBPerp) 393 | pBe = lOB.intersectWithCurve(lBeI)[0] 394 | if pBe.y < minY: 395 | pBe = pO 396 | vOBe = pO.vectorTo(pBe) 397 | pBe = pO.copy() 398 | pBe.translateBy(vOBe) 399 | 400 | # Slope the finger. 401 | try: 402 | slopeCrossSection = createFaceFromCurves([ 403 | adsk.core.Line3D.create(pI, pAe), 404 | adsk.core.Line3D.create(pAe, pA), 405 | adsk.core.Line3D.create(pA, pI), 406 | ]) 407 | slope = createPrism(slopeCrossSection, fingerBWidth) 408 | tempBrepMgr.booleanOperation(finger, slope, adsk.fusion.BooleanTypes.DifferenceBooleanType) 409 | except RuntimeError as e: 410 | # Ignore if slope is too tiny. 411 | if not any('ASM_WIRE_SELF_INTERSECTS' in arg for arg in e.args): 412 | raise 413 | try: 414 | slopeCrossSection = createFaceFromCurves([ 415 | adsk.core.Line3D.create(pI, pB), 416 | adsk.core.Line3D.create(pB, pBe), 417 | adsk.core.Line3D.create(pBe, pI), 418 | ]) 419 | slope = createPrism(slopeCrossSection, -(fingerAWidth + margin)) 420 | tempBrepMgr.booleanOperation(finger, slope, adsk.fusion.BooleanTypes.UnionBooleanType) 421 | slope = createPrism(slopeCrossSection, fingerBWidth + fingerAWidth + margin) 422 | tempBrepMgr.booleanOperation(finger, slope, adsk.fusion.BooleanTypes.UnionBooleanType) 423 | except RuntimeError as e: 424 | # Ignore if slope is too tiny. 425 | if not any('ASM_WIRE_SELF_INTERSECTS' in arg for arg in e.args): 426 | raise 427 | else: 428 | # Joint angle is <= 90°. 429 | pI = adsk.core.Point3D.create(maxX, maxY, minZ) 430 | pA = adsk.core.Point3D.create(maxX - vOB.x, minY, minZ) 431 | pAe = pA 432 | vOBe = vOB 433 | pBe = pB 434 | 435 | if bitRadius > 0: 436 | vOA = adsk.core.Vector3D.create(pA.x - pO.x, 0, 0) 437 | vOBc = vOB.copy() 438 | vOBc.scaleBy(-bitRadius / vOBc.length) 439 | vOBc.add(vOBe) 440 | vOAPerp = adsk.core.Vector3D.create(-vOA.y, vOA.x, 0) 441 | 442 | pAc = pAe.copy() 443 | pAc.x = pAc.x - bitRadius 444 | pBc = pO.copy() 445 | pBc.translateBy(vOBc) 446 | 447 | vAeI = pAe.vectorTo(pI) 448 | vBeI = pBe.vectorTo(pI) 449 | lAeI = adsk.core.InfiniteLine3D.create(pAe, vAeI) 450 | lBeI = adsk.core.InfiniteLine3D.create(pBe, vBeI) 451 | lAcIc = adsk.core.InfiniteLine3D.create(pAc, vAeI) 452 | lBcIc = adsk.core.InfiniteLine3D.create(pBc, vBeI) 453 | pIa = pBc.copy() 454 | pIa.translateBy(vBeI) 455 | pIb = adsk.core.Point3D.create(pI.x - bitRadius, pI.y, pI.z) 456 | pIc = lAcIc.intersectWithCurve(lBcIc)[0] 457 | vAcIc = pAc.vectorTo(pIc) 458 | vBcIc = pBc.vectorTo(pIc) 459 | pIae = lAeI.intersectWithCurve(lBcIc)[0] 460 | pIbe = lBeI.intersectWithCurve(lAcIc)[0] 461 | vAeIae = pAe.vectorTo(pIae) 462 | vBeIbe = pBe.vectorTo(pIbe) 463 | 464 | # Reference points up and down by the bit radius. 465 | zUp = minZ + bitRadius 466 | zDown = minZ - bitRadius 467 | pAeUp = pAe.copy() 468 | pAeUp.z = zUp 469 | pAeDown = pAe.copy() 470 | pAeDown.z = zDown 471 | pBeUp = pBe.copy() 472 | pBeUp.z = zUp 473 | pBeDown = pBe.copy() 474 | pBeDown.z = zDown 475 | pAcUp = pAc.copy() 476 | pAcUp.z = zUp 477 | pAcDown = pAc.copy() 478 | pAcDown.z = zDown 479 | pBcUp = pBc.copy() 480 | pBcUp.z = zUp 481 | pBcDown = pBc.copy() 482 | pBcDown.z = zDown 483 | if isAcute: 484 | pIcDown = pIc.copy() 485 | pIcDown.z = zDown 486 | pIaDown = pIa.copy() 487 | pIaDown.z = zDown 488 | pIbDown = pIb.copy() 489 | pIbDown.z = zDown 490 | pUaDown = pIcDown.copy() 491 | pUaDown.translateBy(vOAPerp) 492 | pUbDown = pIcDown.copy() 493 | pUbDown.translateBy(vOBPerp) 494 | 495 | fingerACutter = finger 496 | fingerBJoiner = tempBrepMgr.copy(finger) 497 | 498 | # Add rounded inside corners to the fingers on body B. 499 | coveCrossSection = createFaceFromCurves([ 500 | adsk.core.Arc3D.createByCenter( 501 | center=pBcDown, 502 | normal=vOBPerp, 503 | referenceVector=vOB, 504 | radius=bitRadius, 505 | startAngle=0, 506 | endAngle=0.5 * math.pi), 507 | adsk.core.Arc3D.createByCenter( 508 | center=pBcUp, 509 | normal=vOBPerp, 510 | referenceVector=vOB, 511 | radius=bitRadius, 512 | startAngle=1.5 * math.pi, 513 | endAngle=0), 514 | adsk.core.Line3D.create(pBeUp, pBeDown), 515 | ]) 516 | cove = createObliquePrism(coveCrossSection, vBeIbe) 517 | tempBrepMgr.booleanOperation(fingerACutter, cove, adsk.fusion.BooleanTypes.UnionBooleanType) 518 | tempBrepMgr.transform(cove, translateUp) 519 | tempBrepMgr.booleanOperation(fingerACutter, cove, adsk.fusion.BooleanTypes.UnionBooleanType) 520 | cove = createObliquePrism(coveCrossSection, vBcIc) 521 | tempBrepMgr.booleanOperation(fingerBJoiner, cove, adsk.fusion.BooleanTypes.UnionBooleanType) 522 | tempBrepMgr.transform(cove, translateUp) 523 | tempBrepMgr.booleanOperation(fingerBJoiner, cove, adsk.fusion.BooleanTypes.UnionBooleanType) 524 | 525 | # Add rounded inside corners to the fingers on body A. 526 | coveCrossSection = createFaceFromCurves([ 527 | adsk.core.Arc3D.createByCenter( 528 | center=pAcUp, 529 | normal=vOAPerp, 530 | referenceVector=vOA, 531 | radius=bitRadius, 532 | startAngle=0, 533 | endAngle=0.5 * math.pi), 534 | adsk.core.Arc3D.createByCenter( 535 | center=pAcDown, 536 | normal=vOAPerp, 537 | referenceVector=vOA, 538 | radius=bitRadius, 539 | startAngle=1.5 * math.pi, 540 | endAngle=0), 541 | adsk.core.Line3D.create(pAeDown, pAeUp), 542 | ]) 543 | cove = createObliquePrism(coveCrossSection, vAcIc) 544 | tempBrepMgr.booleanOperation(fingerACutter, cove, adsk.fusion.BooleanTypes.DifferenceBooleanType) 545 | tempBrepMgr.transform(cove, translateUp) 546 | tempBrepMgr.booleanOperation(fingerACutter, cove, adsk.fusion.BooleanTypes.DifferenceBooleanType) 547 | cove = createObliquePrism(coveCrossSection, vAeIae) 548 | tempBrepMgr.booleanOperation(fingerBJoiner, cove, adsk.fusion.BooleanTypes.DifferenceBooleanType) 549 | tempBrepMgr.transform(cove, translateUp) 550 | tempBrepMgr.booleanOperation(fingerBJoiner, cove, adsk.fusion.BooleanTypes.DifferenceBooleanType) 551 | 552 | # Add dog bones (T-bones) on the inside face of body A. 553 | dogBoneCrossSection = createFaceFromCurves([ 554 | adsk.core.Circle3D.createByCenter( 555 | center=pIc, 556 | normal=vOAPerp, 557 | radius=bitRadius), 558 | ]) 559 | dogBone = createObliquePrism(dogBoneCrossSection, vAeI) 560 | tempBrepMgr.booleanOperation(fingerACutter, dogBone, adsk.fusion.BooleanTypes.UnionBooleanType) 561 | tempBrepMgr.transform(dogBone, translateUp) 562 | tempBrepMgr.booleanOperation(fingerACutter, dogBone, adsk.fusion.BooleanTypes.UnionBooleanType) 563 | if isAcute: 564 | try: 565 | dogBone = createObliquePrism(dogBoneCrossSection, vOAPerp) 566 | tempBrepMgr.booleanOperation(fingerACutter, dogBone, adsk.fusion.BooleanTypes.UnionBooleanType) 567 | tempBrepMgr.transform(dogBone, translateUp) 568 | tempBrepMgr.booleanOperation(fingerACutter, dogBone, adsk.fusion.BooleanTypes.UnionBooleanType) 569 | except RuntimeError as e: 570 | if not any('ASM_OSCULATING_CURVES' in arg for arg in e.args): 571 | raise 572 | try: 573 | dogBoneWedge = createObliquePrism( 574 | createFaceFromCurves([ 575 | adsk.core.Line3D.create(pIcDown, pUaDown), 576 | adsk.core.Line3D.create(pUaDown, pIbDown), 577 | adsk.core.Line3D.create(pIbDown, pIcDown), 578 | ]), 579 | adsk.core.Vector3D.create(0, 0, fingerBWidth + bitDiameter)) 580 | tempBrepMgr.booleanOperation(fingerACutter, dogBoneWedge, adsk.fusion.BooleanTypes.UnionBooleanType) 581 | except RuntimeError as e: 582 | # Ignore if wedge is too tiny. 583 | if not any('ASM_WIRE_SELF_INTERSECTS' in arg for arg in e.args): 584 | raise 585 | 586 | # Add dog bones (T-bones) on the inside face of body B. 587 | dogBoneCrossSection = createFaceFromCurves([ 588 | adsk.core.Circle3D.createByCenter( 589 | center=pIc, 590 | normal=vOBPerp, 591 | radius=bitRadius), 592 | ]) 593 | dogBone = createObliquePrism(dogBoneCrossSection, vBeI) 594 | tempBrepMgr.booleanOperation(fingerBJoiner, dogBone, adsk.fusion.BooleanTypes.DifferenceBooleanType) 595 | tempBrepMgr.transform(dogBone, translateUp) 596 | tempBrepMgr.booleanOperation(fingerBJoiner, dogBone, adsk.fusion.BooleanTypes.DifferenceBooleanType) 597 | if isAcute: 598 | try: 599 | dogBone = createObliquePrism(dogBoneCrossSection, vOBPerp) 600 | tempBrepMgr.booleanOperation(fingerBJoiner, dogBone, adsk.fusion.BooleanTypes.DifferenceBooleanType) 601 | tempBrepMgr.transform(dogBone, translateUp) 602 | tempBrepMgr.booleanOperation(fingerBJoiner, dogBone, adsk.fusion.BooleanTypes.DifferenceBooleanType) 603 | except RuntimeError as e: 604 | if not any('ASM_OSCULATING_CURVES' in arg for arg in e.args): 605 | raise 606 | try: 607 | dogBoneWedge = createObliquePrism( 608 | createFaceFromCurves([ 609 | adsk.core.Line3D.create(pIcDown, pUbDown), 610 | adsk.core.Line3D.create(pUbDown, pIaDown), 611 | adsk.core.Line3D.create(pIaDown, pIcDown), 612 | ]), 613 | adsk.core.Vector3D.create(0, 0, bitDiameter)) 614 | tempBrepMgr.booleanOperation(fingerBJoiner, dogBoneWedge, adsk.fusion.BooleanTypes.DifferenceBooleanType) 615 | tempBrepMgr.transform(dogBoneWedge, translateUp) 616 | tempBrepMgr.booleanOperation(fingerBJoiner, dogBoneWedge, adsk.fusion.BooleanTypes.DifferenceBooleanType) 617 | except RuntimeError as e: 618 | # Ignore if wedge is too tiny. 619 | if not any('ASM_WIRE_SELF_INTERSECTS' in arg for arg in e.args): 620 | raise 621 | 622 | # Move each finger into its final position and combine with body A and body B. 623 | for i in range(0, math.floor(fingers / 2)): 624 | translateUp.setCell(2, 3, margin + fingerAWidth + i * (fingerAWidth + fingerBWidth)) 625 | 626 | finger = tempBrepMgr.copy(fingerACutter) 627 | tempBrepMgr.transform(finger, translateUp) 628 | tempBrepMgr.booleanOperation(finger, overlap, adsk.fusion.BooleanTypes.IntersectionBooleanType) 629 | tempBrepMgr.transform(finger, nominalToJoint) 630 | baseCombines.add(BooleanOperation.difference( 631 | targetBody=bodyA, 632 | toolBody=finger)) 633 | 634 | finger = tempBrepMgr.copy(fingerBJoiner) 635 | tempBrepMgr.transform(finger, translateUp) 636 | tempBrepMgr.booleanOperation(finger, overlap, adsk.fusion.BooleanTypes.IntersectionBooleanType) 637 | tempBrepMgr.transform(finger, nominalToJoint) 638 | baseCombines.add(BooleanOperation.union( 639 | targetBody=bodyB, 640 | toolBody=finger)) 641 | 642 | return baseCombines 643 | 644 | 645 | def getAllButtingFaces(outsideFaces: list[adsk.fusion.BRepFace]) -> list[tuple[ 646 | adsk.fusion.BRepFace, adsk.fusion.BRepFace, adsk.fusion.BRepFace 647 | ]]: 648 | buttingFaces = [] 649 | for faceA, faceB in itertools.permutations(outsideFaces, 2): 650 | for buttingFace in getButtingFaces(faceA, faceB): 651 | buttingFaces.append((faceA, faceB, buttingFace)) 652 | return buttingFaces 653 | 654 | 655 | def getButtingFaces(faceA: adsk.fusion.BRepFace, faceB: adsk.fusion.BRepFace) -> list[adsk.fusion.BRepFace]: 656 | buttingFaces = [] 657 | 658 | if faceA.body == faceB.body: 659 | # Both faces on same body. 660 | return buttingFaces 661 | if faceA.geometry.surfaceType != adsk.core.SurfaceTypes.PlaneSurfaceType: 662 | # Face A is not planar. 663 | return buttingFaces 664 | if faceB.geometry.surfaceType != adsk.core.SurfaceTypes.PlaneSurfaceType: 665 | # Face B is not planar. 666 | return buttingFaces 667 | 668 | # Check all planar faces adjacent to face B. 669 | for edgeOnFaceB in faceB.edges: 670 | for candidateButtingFace in edgeOnFaceB.faces: 671 | if candidateButtingFace == faceB: 672 | # This is face B itself, not an adjacent face. 673 | continue 674 | candidateButtingFaceGeometry = candidateButtingFace.geometry 675 | if candidateButtingFaceGeometry.surfaceType != adsk.core.SurfaceTypes.PlaneSurfaceType: 676 | # Not a planar face. 677 | continue 678 | if not candidateButtingFaceGeometry.isParallelToPlane(faceA.geometry): 679 | # Not parallel to face A. 680 | continue 681 | if candidateButtingFaceGeometry.isCoPlanarTo(faceA.geometry): 682 | # There is zero distance to face A. 683 | continue 684 | 685 | # Check that there is an overlapping exterior face on body A. 686 | for f in faceA.body.shells[0].faces: 687 | if f.geometry.surfaceType != adsk.core.SurfaceTypes.PlaneSurfaceType: 688 | continue 689 | if not candidateButtingFaceGeometry.isCoPlanarTo(f.geometry): 690 | continue 691 | imprint = tempBrepMgr.imprintOverlapBodies( 692 | tempBrepMgr.copy(candidateButtingFace), 693 | tempBrepMgr.copy(f), 694 | False) 695 | if imprint[3].size() > 0: 696 | # This face butts up with a face on body A. 697 | buttingFaces.append(candidateButtingFace) 698 | 699 | return buttingFaces 700 | 701 | 702 | def clamp(value, min, max): 703 | """ 704 | Clamps the given `value` to be between the given `min` and `max` values. 705 | """ 706 | return min if value < min else max if value > max else value 707 | 708 | 709 | _htmlTagRegex = re.compile('<[^>]*>') 710 | def withoutHtml(string): 711 | """ 712 | Removes all HTML tags from the given `string`. 713 | """ 714 | return re.sub(_htmlTagRegex, '', string) 715 | -------------------------------------------------------------------------------- /fusion_brep_util.py: -------------------------------------------------------------------------------- 1 | import adsk.core 2 | import adsk.fusion 3 | from dataclasses import dataclass 4 | import io 5 | 6 | 7 | tempBrepMgr = adsk.fusion.TemporaryBRepManager.get() 8 | 9 | 10 | def dumpBody(body: adsk.fusion.BRepBody, indent = ' ') -> str: 11 | indent2 = indent * 2 12 | out = io.StringIO( 13 | f'{indent}name: {body.name}\n' 14 | f'{indent}isValid: {body.isValid}, isTemporary: {body.isTemporary}, isTransient: {body.isTransient}\n' 15 | f'{indent}bounds: ' 16 | f'{body.boundingBox.minPoint.x} <= x <= {body.boundingBox.maxPoint.x}, ' 17 | f'{body.boundingBox.minPoint.y} <= y <= {body.boundingBox.maxPoint.y}, ' 18 | f'{body.boundingBox.minPoint.z} <= z <= {body.boundingBox.maxPoint.z}\n' 19 | f'{indent}isSolid: {body.isSolid}\n' 20 | f'{indent}volume: {(body.isSolid or None) and f"{body.volume} cm^3"}\n' 21 | f'{indent}parentComponent: {body.parentComponent}\n' 22 | f'{indent}baseFeature: {body.baseFeature}\n' 23 | f'{indent}lumps: {body.lumps.count}\n' 24 | f'{indent}shells: {body.shells.count}\n' 25 | f'{indent}faces: {body.faces.count}\n' 26 | f'{indent}edges: {body.edges.count}\n' 27 | f'{indent}vertices: {body.vertices.count}\n' 28 | f'{indent}wires: {body.wires.count}\n' 29 | ) 30 | for i, lump in enumerate(body.lumps): 31 | for j, shell in enumerate(lump.shells): 32 | out.write( 33 | f'{indent}lumps[{i}].shells[{j}]:\n' 34 | f'{indent2}bounds: ' 35 | f'{shell.boundingBox.minPoint.x} <= x <= {shell.boundingBox.maxPoint.x}, ' 36 | f'{shell.boundingBox.minPoint.y} <= y <= {shell.boundingBox.maxPoint.y}, ' 37 | f'{shell.boundingBox.minPoint.z} <= z <= {shell.boundingBox.maxPoint.z}\n' 38 | f'{indent2}isClosed: {shell.isClosed}\n' 39 | f'{indent2}isVoid: {shell.isVoid}\n' 40 | f'{indent2}area: {shell.area} cm^2\n' 41 | f'{indent2}volume: {shell.volume} cm^3\n' 42 | f'{indent2}faces: {shell.faces.count}\n' 43 | f'{indent2}edges: {shell.edges.count}\n' 44 | f'{indent2}vertices: {shell.vertices.count}\n' 45 | ) 46 | for attribute in body.attributes or []: 47 | out.write(f'{indent}attribute: {attribute.groupName}/{attribute.name}: {attribute.value}\n') 48 | return out.getvalue() 49 | 50 | 51 | def definitionOfBody(body: adsk.fusion.BRepBody) -> adsk.fusion.BRepBodyDefinition: 52 | """ 53 | Given a `body` create a `BRepBodyDefinition` for it. 54 | The body definition can be used to create a similar body with modifications. 55 | """ 56 | bodyDefinition = adsk.fusion.BRepBodyDefinition.create() 57 | bodyDefinition.doFullHealing = False 58 | for lump in body.lumps: 59 | lumpDefinition = bodyDefinition.lumpDefinitions.add() 60 | for shell in lump.shells: 61 | shellDefinition = lumpDefinition.shellDefinitions.add() 62 | for face in shell.faces: 63 | faceDefinition = shellDefinition.faceDefinitions.add( 64 | face.geometry, face.isParamReversed) 65 | for loop in face.loops: 66 | loopDefinition = faceDefinition.loopDefinitions.add() 67 | for coEdge in loop.coEdges: 68 | edge = coEdge.edge 69 | loopDefinition.bRepCoEdgeDefinitions.add( 70 | bodyDefinition.createEdgeDefinitionByCurve( 71 | bodyDefinition.createVertexDefinition(edge.startVertex.geometry), 72 | bodyDefinition.createVertexDefinition(edge.endVertex.geometry), 73 | edge.geometry), 74 | coEdge.isParamReversed) 75 | wire = shell.wire 76 | if wire: 77 | wireDefinition = shellDefinition.wireDefinition 78 | for coEdge in wire.coEdges: 79 | edge = coEdge.edge 80 | wireDefinition.wireEdgeDefinitions.add( 81 | bodyDefinition.createVertexDefinition(edge.startVertex.geometry), 82 | bodyDefinition.createVertexDefinition(edge.endVertex.geometry), 83 | edge.geometry) 84 | return bodyDefinition 85 | 86 | 87 | def createSimpleBox(x, y, z, dx, dy, dz) -> adsk.fusion.BRepBody: 88 | """ 89 | Create a box in the current coordinate system given min point coordinates and dimensions. 90 | """ 91 | return tempBrepMgr.createBox(adsk.core.OrientedBoundingBox3D.create( 92 | adsk.core.Point3D.create( 93 | x + dx / 2, 94 | y + dy / 2, 95 | z + dz / 2), 96 | adsk.core.Vector3D.create(1, 0, 0), 97 | adsk.core.Vector3D.create(0, 1, 0), 98 | dx, 99 | dy, 100 | dz)) 101 | 102 | 103 | def createFaceFromCurves(curves: list[adsk.core.Curve3D]) -> adsk.fusion.BRepFace: 104 | """ 105 | Create a face (without holes) from the given closed planar `curves`. 106 | """ 107 | return tempBrepMgr.createFaceFromPlanarWires([ 108 | tempBrepMgr.createWireFromCurves(curves)[0]]).faces[0] 109 | 110 | 111 | def createPrism(base: adsk.fusion.BRepFace, height: float) -> adsk.fusion.BRepBody: 112 | """ 113 | Create a prism using the given `base` face and `height`. 114 | Use a negative `height` to reverse the direction of the prism. 115 | Any holes in the `base` face will be ignored and filled-in in the resulting solid prism. 116 | """ 117 | assert base.geometry.surfaceType == adsk.core.SurfaceTypes.PlaneSurfaceType 118 | direction = base.geometry.normal 119 | direction.scaleBy(height) 120 | return createObliquePrism(base, direction) 121 | 122 | 123 | def createObliquePrism(base: adsk.fusion.BRepFace, direction: adsk.core.Vector3D) -> adsk.fusion.BRepBody: 124 | """ 125 | Create an oblique prism using the given `base` face and sweep `direction`. 126 | Any holes in the `base` face will be ignored and filled-in in the resulting solid prism. 127 | """ 128 | assert base.geometry.surfaceType == adsk.core.SurfaceTypes.PlaneSurfaceType 129 | 130 | translation = adsk.core.Matrix3D.create() 131 | translation.translation = direction 132 | 133 | # Create closed wires for both bases. 134 | baseWireA, _ = tempBrepMgr.createWireFromCurves( 135 | [e.geometry for e in base.loops[0].edges], False) 136 | baseWireB = tempBrepMgr.copy(baseWireA) 137 | tempBrepMgr.transform(baseWireB, translation) 138 | 139 | # Create both bases. 140 | baseFaceA = tempBrepMgr.createFaceFromPlanarWires([baseWireA]) 141 | baseFaceB = tempBrepMgr.createFaceFromPlanarWires([baseWireB]) 142 | 143 | # Create the side walls. 144 | sideFaces = tempBrepMgr.createRuledSurface(baseWireA.wires[0], baseWireB.wires[0]) 145 | 146 | # Put all faces together. 147 | prism = sideFaces 148 | tempBrepMgr.booleanOperation(prism, baseFaceA, adsk.fusion.BooleanTypes.UnionBooleanType) 149 | tempBrepMgr.booleanOperation(prism, baseFaceB, adsk.fusion.BooleanTypes.UnionBooleanType) 150 | 151 | # Recreate the prism to make it a solid body. 152 | return definitionOfBody(prism).createBody() 153 | 154 | 155 | def boundingBoxBody(body: adsk.fusion.BRepBody, margin = 0) -> adsk.fusion.BRepBody: 156 | """ 157 | Create a body that represents the bounding box of the given `body`. 158 | """ 159 | boundingBox = body.boundingBox 160 | minX, minY, minZ = boundingBox.minPoint.asArray() 161 | maxX, maxY, maxZ = boundingBox.maxPoint.asArray() 162 | return tempBrepMgr.createBox(adsk.core.OrientedBoundingBox3D.create( 163 | adsk.core.Point3D.create( 164 | (minX + maxX) / 2, 165 | (minY + maxY) / 2, 166 | (minZ + maxZ) / 2), 167 | adsk.core.Vector3D.create(1, 0, 0), 168 | adsk.core.Vector3D.create(0, 1, 0), 169 | maxX - minX + margin, 170 | maxY - minY + margin, 171 | maxZ - minZ + margin)) 172 | 173 | 174 | def aContainingBody(body: adsk.fusion.BRepBody) -> adsk.fusion.BRepBody: 175 | """ 176 | Create a body that is guaranteed to entirely contain the given `body`. 177 | 178 | TODO: Is a box or a sphere more efficient? 179 | """ 180 | boundingBox = body.boundingBox 181 | minPoint = boundingBox.minPoint 182 | diagonal = minPoint.vectorTo(boundingBox.maxPoint) 183 | diagonal.scaleBy(0.5) 184 | center = minPoint.copy() 185 | center.translateBy(diagonal) 186 | return tempBrepMgr.createSphere(center, diagonal.length + 1) 187 | 188 | # return boundingBoxBody(body, 1) 189 | 190 | 191 | def anExternalBody(body: adsk.fusion.BRepBody) -> adsk.fusion.BRepBody: 192 | """ 193 | Create a body that is guaranteed NOT to intersect with the given solid `body`. 194 | 195 | TODO: Is a box or a sphere more efficient? 196 | """ 197 | outsidePoint = body.boundingBox.maxPoint.copy() 198 | outsidePoint.x = outsidePoint.x + 2 199 | return tempBrepMgr.createSphere(outsidePoint, 1) 200 | 201 | # outsidePoint = body.boundingBox.maxPoint.copy() 202 | # outsidePoint.x = outsidePoint.x + 2 203 | # return tempBrepMgr.createBox(adsk.core.OrientedBoundingBox3D.create( 204 | # outsidePoint, 205 | # adsk.core.Vector3D.create(1, 0, 0), 206 | # adsk.core.Vector3D.create(0, 1, 0), 207 | # 1, 208 | # 1, 209 | # 1)) 210 | 211 | 212 | def anInternalBody(body: adsk.fusion.BRepBody) -> adsk.fusion.BRepBody: 213 | """ 214 | Create a body that is guaranteed to be entirely contained within the given solid `body`. 215 | """ 216 | tinySphere = tempBrepMgr.createSphere(body.faces[0].pointOnFace, 2**-16) 217 | tempBrepMgr.booleanOperation(tinySphere, body, adsk.fusion.BooleanTypes.IntersectionBooleanType) 218 | return tinySphere 219 | 220 | 221 | @dataclass 222 | class BooleanOperation: 223 | """ 224 | Represents a single boolean operation of a tool body acting on a target body to either: 225 | * cut the target body, 226 | * intersect with the target body, or 227 | * join with the target body 228 | """ 229 | targetBody: adsk.fusion.BRepBody 230 | toolBody: adsk.fusion.BRepBody 231 | operation: adsk.fusion.BooleanTypes 232 | 233 | @classmethod 234 | def difference(cls, targetBody: adsk.fusion.BRepBody, toolBody: adsk.fusion.BRepBody) -> 'BooleanOperation': 235 | return cls( 236 | targetBody=targetBody, 237 | toolBody=toolBody, 238 | operation=adsk.fusion.BooleanTypes.DifferenceBooleanType) 239 | 240 | @classmethod 241 | def intersection(cls, targetBody: adsk.fusion.BRepBody, toolBody: adsk.fusion.BRepBody) -> 'BooleanOperation': 242 | return cls( 243 | targetBody=targetBody, 244 | toolBody=toolBody, 245 | operation=adsk.fusion.BooleanTypes.IntersectBooleanType) 246 | 247 | @classmethod 248 | def union(cls, targetBody: adsk.fusion.BRepBody, toolBody: adsk.fusion.BRepBody) -> 'BooleanOperation': 249 | return cls( 250 | targetBody=targetBody, 251 | toolBody=toolBody, 252 | operation=adsk.fusion.BooleanTypes.UnionBooleanType) 253 | -------------------------------------------------------------------------------- /fusion_cf_addin.py: -------------------------------------------------------------------------------- 1 | import adsk.core 2 | import adsk.fusion 3 | import contextlib 4 | import itertools 5 | 6 | from .fusion_util import * 7 | 8 | 9 | class FusionCustomFeatureAddIn: 10 | """ 11 | Abstract base class for Fusion 360 Custom Feature Add-Ins. 12 | 13 | A subclass should override specific methods as needed. 14 | """ 15 | def __init__(self, *, 16 | baseCommandId: str, 17 | name: str, 18 | createTooltip: str, 19 | editTooltip: str, 20 | resourceFolder: str, 21 | toolbarControls, 22 | ): 23 | self.baseCommandId = baseCommandId 24 | self._computeDisabled = False 25 | self._editedCustomFeature: adsk.fusion.CustomFeature = None 26 | self._savedTimelineObject: adsk.fusion.TimelineObject = None 27 | 28 | # Create all handlers. 29 | self._createHandler = newEventHandler(self.onCreate, adsk.core.CommandCreatedEventHandler) 30 | self._editHandler = newEventHandler(self.onEdit, adsk.core.CommandCreatedEventHandler) 31 | self._computeHandler = newEventHandler(self.onCompute, adsk.fusion.CustomFeatureEventHandler) 32 | self._activateHandler = newEventHandler(self.onActivate, adsk.core.CommandEventHandler) 33 | self._preSelectHandler = newEventHandler(self.onPreSelect, adsk.core.SelectionEventHandler) 34 | self._validateInputsHandler = newEventHandler(self.onValidateInputs, adsk.core.ValidateInputsEventHandler) 35 | self._previewHandler = newEventHandler(self.onPreview, adsk.core.CommandEventHandler) 36 | self._executeCreateHandler = newEventHandler(self.onExecuteCreate, adsk.core.CommandEventHandler) 37 | self._executeEditHandler = newEventHandler(self.onExecuteEdit, adsk.core.CommandEventHandler) 38 | 39 | userInterface = adsk.core.Application.get().userInterface 40 | 41 | # Add the command definition for the "create" command. 42 | createCommandDef = userInterface.commandDefinitions.addButtonDefinition( 43 | baseCommandId + 'Create', 44 | name, 45 | createTooltip, 46 | resourceFolder) 47 | createCommandDef.commandCreated.add(self._createHandler) 48 | 49 | # Create the command definition for the "edit" command. 50 | editCommandDef = userInterface.commandDefinitions.addButtonDefinition( 51 | baseCommandId + 'Edit', 52 | name, 53 | editTooltip, 54 | '') 55 | editCommandDef.commandCreated.add(self._editHandler) 56 | 57 | # Create the custom feature definition. 58 | self._customFeatureDef = adsk.fusion.CustomFeatureDefinition.create( 59 | baseCommandId + 'Create', 60 | name, 61 | resourceFolder) 62 | self._customFeatureDef.editCommandId = baseCommandId + 'Edit' 63 | self._customFeatureDef.customFeatureCompute.add(self._computeHandler) 64 | 65 | # Add "create" control(s) to the toolbar. 66 | for toolbarControl in toolbarControls: 67 | control = (userInterface 68 | .workspaces.itemById(toolbarControl['workspace']) 69 | .toolbarPanels.itemById(toolbarControl['panel']) 70 | .controls.addCommand( 71 | createCommandDef, 72 | toolbarControl.get('beforeControl') or 73 | toolbarControl.get('afterControl') or '', # positionID 74 | toolbarControl.get('beforeControl') is not None # isBefore 75 | ) 76 | ) 77 | control.isPromotedByDefault = toolbarControl.get('promote', False) 78 | 79 | def __del__(self): 80 | userInterface = adsk.core.Application.get().userInterface 81 | 82 | commandIds = [self.baseCommandId + suffix for suffix in ['Create', 'Edit']] 83 | 84 | # Remove UI elements and commands. 85 | for collection in itertools.chain( 86 | (toolbar.controls for toolbar in userInterface.toolbars), 87 | (toolbar.controls for toolbar in userInterface.allToolbarPanels), 88 | [userInterface.commandDefinitions] 89 | ) : 90 | for commandId in commandIds: 91 | command = collection.itemById(commandId) 92 | if command: 93 | command.deleteMe() 94 | 95 | def onCreate(self, eventArgs: adsk.core.CommandCreatedEventArgs): 96 | self._computeDisabled = False 97 | self._editedCustomFeature = None 98 | self._savedTimelineObject = None 99 | 100 | command = eventArgs.command 101 | self.createInputs(command, self.defaultParams()) 102 | command.preSelect.add(self._preSelectHandler) 103 | command.validateInputs.add(self._validateInputsHandler) 104 | command.executePreview.add(self._previewHandler) 105 | command.execute.add(self._executeCreateHandler) 106 | 107 | def onEdit(self, eventArgs: adsk.core.CommandCreatedEventArgs): 108 | self._computeDisabled = False 109 | self._editedCustomFeature = None 110 | self._savedTimelineObject = None 111 | 112 | userInterface = adsk.core.Application.get().userInterface 113 | 114 | command = eventArgs.command 115 | customFeature = userInterface.activeSelections[0].entity 116 | self._editedCustomFeature = customFeature 117 | params = self.customFeatureToParams(customFeature) 118 | 119 | self.createInputs(command, params) 120 | command.activate.add(self._activateHandler) 121 | command.preSelect.add(self._preSelectHandler) 122 | command.validateInputs.add(self._validateInputsHandler) 123 | command.executePreview.add(self._previewHandler) 124 | command.execute.add(self._executeEditHandler) 125 | 126 | def onCompute(self, eventArgs: adsk.fusion.CustomFeatureEventArgs): 127 | if self._computeDisabled: 128 | return 129 | 130 | customFeature = eventArgs.customFeature 131 | 132 | # Save the current position of the timeline. 133 | savedTimelineObject = currentTimelineObject() 134 | 135 | customFeature.timelineObject.rollTo(rollBefore=True) 136 | params = self.customFeatureToParams(customFeature) 137 | features = list(customFeature.features) 138 | customFeature.setStartAndEndFeatures(None, None) 139 | customFeature.timelineObject.rollTo(rollBefore=False) 140 | features = self.createOrUpdateChildFeatures(params, 141 | existingFeatures=features, 142 | allowFeatureCreationAndDeletion=False) 143 | customFeature.timelineObject.rollTo(rollBefore=True) 144 | if features: 145 | customFeature.setStartAndEndFeatures(features[0], features[-1]) 146 | customFeature.timelineObject.rollTo(rollBefore=False) 147 | 148 | # Roll the timeline to its previous position. 149 | savedTimelineObject.rollTo(False) 150 | 151 | def onActivate(self, eventArgs: adsk.core.CommandEventArgs): 152 | command = eventArgs.command 153 | customFeature = self._editedCustomFeature 154 | 155 | if not customFeature: 156 | # Not currently editing an existing custom feature. 157 | return 158 | 159 | self._savedTimelineObject = currentTimelineObject() 160 | customFeature.timelineObject.rollTo(rollBefore = True) 161 | command.beginStep() 162 | 163 | params = self.customFeatureToParams(customFeature) 164 | with self.computeDisabled(): 165 | self.paramsToInputs(params, command.commandInputs) 166 | 167 | command.doExecutePreview() 168 | 169 | def onPreSelect(self, eventArgs: adsk.core.SelectionEventArgs): 170 | eventArgs.isSelectable = False 171 | eventArgs.isSelectable = self.canSelect(eventArgs.selection.entity, eventArgs.activeInput) 172 | 173 | def onValidateInputs(self, eventArgs: adsk.core.ValidateInputsEventArgs): 174 | eventArgs.areInputsValid = False 175 | eventArgs.areInputsValid = self.areInputsValid(eventArgs.inputs) 176 | 177 | def onPreview(self, eventArgs: adsk.core.CommandEventArgs): 178 | if self._computeDisabled: 179 | return 180 | 181 | command = eventArgs.command 182 | customFeature = self._editedCustomFeature 183 | params = self.inputsToParams(command.commandInputs) 184 | 185 | with self.computeDisabled(): 186 | if customFeature and customFeature.timelineObject: 187 | # Previewing changes to an existing feature. 188 | self._updateCustomFeature(params, customFeature) 189 | else: 190 | # Previewing a new feature. 191 | self._createCustomFeature(params) 192 | 193 | eventArgs.isValidResult = True 194 | 195 | def onExecuteCreate(self, eventArgs: adsk.core.CommandEventArgs): 196 | params = self.inputsToParams(eventArgs.command.commandInputs) 197 | 198 | with self.computeDisabled(): 199 | self._createCustomFeature(params) 200 | 201 | eventArgs.executeFailed = False 202 | 203 | def onExecuteEdit(self, eventArgs: adsk.core.CommandEventArgs): 204 | command = eventArgs.command 205 | customFeature = self._editedCustomFeature 206 | params = self.inputsToParams(command.commandInputs) 207 | 208 | with self.computeDisabled(): 209 | self._updateCustomFeature(params, customFeature) 210 | 211 | # Roll the timeline to its previous position. 212 | if self._savedTimelineObject: 213 | self._savedTimelineObject.rollTo(False) 214 | self._savedTimelineObject = None 215 | 216 | eventArgs.executeFailed = False 217 | 218 | def _createCustomFeature(self, params): 219 | design: adsk.fusion.Design = adsk.core.Application.get().activeProduct 220 | customFeatures = design.activeComponent.features.customFeatures 221 | unitsManager = adsk.core.Application.get().activeProduct.unitsManager 222 | 223 | customFeatureInput = customFeatures.createInput(self._customFeatureDef) 224 | 225 | # Add all custom feature parameters. 226 | for name, value in self.getCustomParameters(params).items(): 227 | customFeatureInput.addCustomParameter( 228 | name, self.getCustomParameterDescription(name), 229 | value.valueInput, unitsManager.formatUnits(value.units)) 230 | 231 | # Add all dependencies. 232 | for name, entity in self.getDependencies(params).items(): 233 | customFeatureInput.addDependency(name, entity) 234 | 235 | # Create an empty custom feature first. 236 | customFeature = customFeatures.add(customFeatureInput) 237 | 238 | # Add all custom named values. 239 | for name, value in self.getCustomNamedValues(params).items(): 240 | customFeature.customNamedValues.addOrSetValue(name, value) 241 | 242 | # Create all child features. 243 | features = self.createOrUpdateChildFeatures(params, 244 | existingFeatures=[], 245 | allowFeatureCreationAndDeletion=True) 246 | customFeature.timelineObject.rollTo(rollBefore=True) 247 | if features: 248 | customFeature.setStartAndEndFeatures(features[0], features[-1]) 249 | customFeature.timelineObject.rollTo(rollBefore=False) 250 | 251 | def _updateCustomFeature(self, params, customFeature: adsk.fusion.CustomFeature): 252 | customFeature.timelineObject.rollTo(rollBefore=True) 253 | 254 | # Update all custom feature parameters. 255 | for name, value in self.getCustomParameters(params).items(): 256 | customFeature.parameters.itemById(name).expression = value.expression 257 | 258 | # Update all dependencies. 259 | customFeature.dependencies.deleteAll() 260 | for name, entity in self.getDependencies(params).items(): 261 | customFeature.dependencies.add(name, entity) 262 | 263 | # Update all custom named values. 264 | for name, value in self.getCustomNamedValues(params).items(): 265 | customFeature.customNamedValues.addOrSetValue(name, value) 266 | 267 | # Update child features. 268 | customFeature.timelineObject.rollTo(rollBefore=True) 269 | features = list(customFeature.features) 270 | customFeature.setStartAndEndFeatures(None, None) 271 | customFeature.timelineObject.rollTo(rollBefore=False) 272 | features = self.createOrUpdateChildFeatures(params, 273 | existingFeatures=features, 274 | allowFeatureCreationAndDeletion=True) 275 | customFeature.timelineObject.rollTo(rollBefore=True) 276 | if features: 277 | customFeature.setStartAndEndFeatures(features[0], features[-1]) 278 | customFeature.timelineObject.rollTo(rollBefore=False) 279 | 280 | def createInputs(self, command: adsk.core.Command, params): 281 | pass 282 | 283 | def canSelect(self, entity, forInput: adsk.core.SelectionCommandInput) -> bool: 284 | return True 285 | 286 | def areInputsValid(self, commandInputs: adsk.core.CommandInputs) -> bool: 287 | return True 288 | 289 | def defaultParams(self): 290 | return {} 291 | 292 | def paramsToInputs(self, params, commandInputs: adsk.core.CommandInputs): 293 | pass 294 | 295 | def inputsToParams(self, commandInputs: adsk.core.CommandInputs): 296 | return self.defaultParams() 297 | 298 | def customFeatureToParams(self, feature: adsk.fusion.CustomFeature): 299 | return self.defaultParams() 300 | 301 | def getCustomParameters(self, params) -> dict[str, Parameter]: 302 | return {} 303 | 304 | def getCustomParameterDescriptions(self) -> dict[str, str]: 305 | return {} 306 | 307 | def getCustomParameterDescription(self, parameterId: str) -> str: 308 | return self.getCustomParameterDescriptions().get(parameterId, parameterId) 309 | 310 | def getCustomNamedValues(self, params) -> dict[str, str]: 311 | return {} 312 | 313 | def getDependencies(self, params) -> dict[str, adsk.core.Base]: 314 | return {} 315 | 316 | def createOrUpdateChildFeatures(self, 317 | params, 318 | existingFeatures: list[adsk.fusion.Feature], 319 | allowFeatureCreationAndDeletion: bool 320 | ) -> list[adsk.fusion.Feature]: 321 | return existingFeatures 322 | 323 | @contextlib.contextmanager 324 | def computeDisabled(self): 325 | computeDisabled = self._computeDisabled 326 | self._computeDisabled = True 327 | yield 328 | self._computeDisabled = computeDisabled 329 | -------------------------------------------------------------------------------- /fusion_util.py: -------------------------------------------------------------------------------- 1 | import adsk.core 2 | import adsk.fusion 3 | from functools import wraps 4 | import io 5 | import inspect 6 | import traceback 7 | from typing import Union 8 | 9 | 10 | def with_logging(f): 11 | @wraps(f) 12 | def wrapper(*args, **kwargs): 13 | log(f'Enter {f.__name__}') 14 | result = f(*args, **kwargs) 15 | log(f'Exit {f.__name__} -> {result}') 16 | return result 17 | return wrapper 18 | 19 | 20 | def log(message): 21 | userInterface = adsk.core.Application.get().userInterface 22 | userInterface.palettes.itemById('TextCommands').writeText(message) 23 | 24 | 25 | def messageBox(message): 26 | adsk.core.Application.get().userInterface.messageBox(message) 27 | 28 | 29 | def handleException(): 30 | frameInfo = inspect.stack()[1] 31 | prefix = '' 32 | if 'self' in frameInfo.frame.f_locals: 33 | prefix = frameInfo.frame.f_locals['self'].__class__.__name__ + '.' 34 | log(f'{prefix}{frameInfo.function}: {traceback.format_exc()}\n') 35 | 36 | 37 | def newObjectCollection(objects) -> adsk.core.ObjectCollection: 38 | collection = adsk.core.ObjectCollection.create() 39 | for object in objects: 40 | collection.add(object) 41 | return collection 42 | 43 | 44 | def newEventHandler(handler, superclass): 45 | class EventHandler(superclass): 46 | def notify(self, eventArgs): 47 | try: 48 | handler(eventArgs) 49 | except: 50 | handleException() 51 | return EventHandler() 52 | 53 | 54 | def currentTimelineObject() -> adsk.fusion.TimelineObject: 55 | design: adsk.fusion.Design = adsk.core.Application.get().activeProduct 56 | timeline = design.timeline 57 | return timeline[timeline.markerPosition - 1] 58 | 59 | 60 | def newValueInput(value) -> adsk.core.ValueInput: 61 | if isinstance(value, str): 62 | return adsk.core.ValueInput.createByString(value) 63 | if isinstance(value, (float, int)): 64 | return adsk.core.ValueInput.createByReal(value) 65 | if isinstance(value, bool): 66 | return adsk.core.ValueInput.createByBoolean(value) 67 | if isinstance(value, adsk.core.Base): 68 | return adsk.core.ValueInput.createByObject(value) 69 | return None 70 | 71 | 72 | def cmOrIn(centimeters: float, inches: float) -> float: 73 | # Choose a length in centimeters or inches based on user preferences. 74 | design: adsk.fusion.Design = adsk.core.Application.get().activeProduct 75 | if design and design.unitsManager.defaultLengthUnits in ('in', 'ft'): 76 | return inches * 2.54 77 | else: 78 | return centimeters 79 | 80 | 81 | class UserInputError(ValueError): 82 | def __init__(self, input, message): 83 | super().__init__(f'{input.name} {message}') 84 | self.input = input 85 | self.message = message 86 | 87 | 88 | class Parameter: 89 | # Indicates that all units are forbidden within an expression. 90 | # Note that this is different than allowing ANY units. 91 | UNITLESS = 'rad/rad' 92 | 93 | def __init__(self, 94 | value: Union[ 95 | str, 96 | 'Parameter', 97 | adsk.fusion.Parameter, 98 | adsk.core.ValueCommandInput, 99 | adsk.core.FloatSpinnerCommandInput, 100 | adsk.core.IntegerSpinnerCommandInput, 101 | adsk.core.DistanceValueCommandInput, 102 | adsk.core.AngleValueCommandInput, 103 | adsk.core.SliderCommandInput], 104 | units: str = UNITLESS 105 | ): 106 | if isinstance(value, adsk.core.Base): 107 | if isinstance(value, (adsk.core.ValueCommandInput, adsk.core.FloatSpinnerCommandInput)): 108 | self._expression = value.expression 109 | self._units = value.unitType or self.UNITLESS 110 | return 111 | if isinstance(value, adsk.core.IntegerSpinnerCommandInput): 112 | self._expression = str(value.value) 113 | self._units = self.UNITLESS 114 | return 115 | if isinstance(value, adsk.core.DistanceValueCommandInput): 116 | unitsManager = adsk.core.Application.get().activeProduct.unitsManager 117 | self._expression = value.expression 118 | self._units = unitsManager.defaultLengthUnits 119 | return 120 | if isinstance(value, adsk.core.AngleValueCommandInput): 121 | self._expression = value.expression 122 | self._units = 'deg' 123 | return 124 | if isinstance(value, adsk.core.SliderCommandInput): 125 | self._expression = value.expressionOne 126 | self._units = value.unitType or self.UNITLESS 127 | return 128 | if isinstance(value, (adsk.fusion.Parameter, Parameter)): 129 | self._expression = value.expression 130 | self._units = value.unit or self.UNITLESS 131 | return 132 | self._expression = str(value) 133 | self._units = units 134 | 135 | @classmethod 136 | def length(cls, centimeters: float) -> 'Parameter': 137 | """ 138 | Create a length specified in Fusion's internal units (centimeters), and converts 139 | it to the default length units as per user preferences (usually mm, cm, m, in, ft). 140 | """ 141 | unitsManager = adsk.core.Application.get().activeProduct.unitsManager 142 | lengthUnits = unitsManager.defaultLengthUnits 143 | return cls( 144 | unitsManager.formatInternalValue(centimeters, lengthUnits, True), 145 | lengthUnits) 146 | 147 | @property 148 | def value(self) -> float: 149 | unitsManager = adsk.core.Application.get().activeProduct.unitsManager 150 | return unitsManager.evaluateExpression(self._expression, self._units) 151 | 152 | @property 153 | def expression(self) -> str: 154 | return self._expression 155 | 156 | @property 157 | def unit(self) -> str: 158 | return self._units 159 | 160 | @property 161 | def units(self) -> str: 162 | return self._units 163 | 164 | @property 165 | def valueInput(self) -> adsk.core.ValueInput: 166 | return adsk.core.ValueInput.createByString(self._expression) 167 | 168 | def __repr__(self) -> str: 169 | return f'Parameter({repr(self.expression)}, {repr(self.units)})' 170 | 171 | 172 | class EntityRef: 173 | def __init__(self, entityOrToken): 174 | if not isinstance(entityOrToken, str): 175 | entityOrToken = entityOrToken.entityToken 176 | self._token = entityOrToken 177 | 178 | @property 179 | def entity(self): 180 | design: adsk.fusion.Design = adsk.core.Application.get().activeProduct 181 | entities = design.findEntityByToken(self._token) 182 | if len(entities): 183 | return entities[0] 184 | return None 185 | 186 | @property 187 | def entityToken(self): 188 | return self._token 189 | 190 | def __repr__(self) -> str: 191 | entity = self.entity 192 | return f'EntityRef({entity and f"<{entity.__class__.__name__}>"})' 193 | 194 | 195 | def dumpMenus() -> str: 196 | out = io.StringIO() 197 | userInterface = adsk.core.Application.get().userInterface 198 | for toolbar in userInterface.toolbars: 199 | try: 200 | for control in toolbar.controls: 201 | out.write(f'Toolbar: {toolbar.id}/{control.id}\n') 202 | except: 203 | pass 204 | for workspace in userInterface.workspaces: 205 | try: 206 | for toolbar in workspace.toolbarPanels: 207 | for control in toolbar.controls: 208 | out.write(f'Workspace Panel: {workspace.id}/{toolbar.id}/{control.id}\n') 209 | except: 210 | pass 211 | return out.getvalue() 212 | -------------------------------------------------------------------------------- /privacy_policy.txt: -------------------------------------------------------------------------------- 1 | This Fusion 360 Add-In does not collect any data. 2 | No user information or usage data is collected, saved nor shared with anybody. 3 | --------------------------------------------------------------------------------