freecad-cam/Mod/cam-dev/ref-fusion/CAM360/ManufacturingAdvisor/CAMFunctionUtils.py

2224 lines
81 KiB
Python

"""
Utility functions for the CAM function calling tool implementations.
"""
import re
import time
import unicodedata
from collections.abc import Callable
from enum import Flag, StrEnum, auto
from typing import Any
import adsk.cam
import adsk.core
import adsk.fusion
from CAMFunctionContext import CAMFunctionContext
from CAMFunctionTasks import CAMFunctionTaskContext
from ToolDefinition import (
TOOL_LIBRARY_SEARCH_PRIORITY,
ToolDefinition,
ToolLibraryDefinition,
ToolLibraryType,
)
class MissingObjectReason(StrEnum):
NONE = auto()
NO_MATCHING_NAME = auto()
NO_MATCHING_REGEX = auto()
NO_OBJECTS = auto()
class HoleAttributes(Flag):
SIMPLE = auto()
COUNTERBORE = auto()
THROUGH = auto()
BLIND = auto()
CHAMFER = auto()
# This enum should be in ModelInput.py, but is needed in multiple files
class ModelInputMode(StrEnum):
"""
Enum representing different modes for model input.
"""
NONE = "none"
ALL = "all"
NAMED = "named"
MANUFACTURING_MODEL = "manufacturing_model"
ACTIVE_SELECTION = "active_selection"
class CAMObjectType(StrEnum):
"""
Enum representing different types of manufacturing objects.
"""
SETUP = "setup"
OPERATION = "operation"
FOLDER = "folder"
PATTERN = "pattern"
MANUFACTURING_MODEL = "manufacturingModel"
ANY = "any"
def includesType(self, objectType) -> bool:
"""
Check if this object type includes the given object type.
ANY will also be included in the check.
"""
return self == objectType or self == CAMObjectType.ANY
def canBeUnderSetup(self) -> bool:
"""
Check if this object type is a type that can be located under a setup.
"""
match self:
case (
CAMObjectType.OPERATION
| CAMObjectType.FOLDER
| CAMObjectType.PATTERN
| CAMObjectType.ANY
):
return True
case CAMObjectType.SETUP | CAMObjectType.MANUFACTURING_MODEL:
return False
case _:
return False
# Boilerplate initialization
def init():
"""
Boilerplate code for CAM functions to retrieve the application, document,
and CAM workspace.
"""
app = adsk.core.Application.get()
ui = app.userInterface
# Use active document
doc = app.activeDocument
# Get the CAM product
products = doc.products
cam = adsk.cam.CAM.cast(products.itemByProductType("CAMProductType"))
return app, ui, doc, products, cam
def designProduct() -> adsk.fusion.Design:
"""Return the Design Product (workspace) of the active document."""
_, _, _, products, _ = init()
design = products.itemByProductType("DesignProductType")
return adsk.fusion.Design.cast(design)
# String helpers
def isNonEmptyString(string: str | None) -> bool:
"""Check if the given string is a valid string (not None and not empty)."""
return string is not None and string != ""
def getActionStringFromMode(mode: str) -> str:
"""
Get the string of the operation mode ("x", "u" or "d").
"""
function_type_map = {"x": "creating", "u": "updating", "d": "deleting"}
return function_type_map.get(mode, "unknown")
def getOperationTypeString(operationType: adsk.cam.OperationTypes) -> str:
"""
Get the string of the operation type.
"""
operation_type_map = {
adsk.cam.OperationTypes.MillingOperation: "milling",
adsk.cam.OperationTypes.TurningOperation: "turning",
adsk.cam.OperationTypes.JetOperation: "jet",
adsk.cam.OperationTypes.AdditiveOperation: "additive",
}
return operation_type_map.get(operationType, "Unknown")
def getListAsString(list: list | None) -> str:
"""
Get the string of a list of names from a list of strings.
e.g. ["op1", "op2", "op3"] -> "'op1', 'op2', 'op3'"
"""
if list is None or len(list) == 0:
return ""
return ", ".join([f"'{item}'" for item in list])
def extractNameAndRegexEnabled(nameArg: dict | str | None) -> tuple[str, bool]:
"""
Extract the name string and regex_enabled flag from the given argument.
If the argument is None, return an empty string and False.
If the argument is a string, return it and False.
If the argument is a dictionary, return the "name" value and the "regex"
value (defaulting to False if not present).
"""
if nameArg is None:
return "", False
elif isinstance(nameArg, str):
return nameArg, False
elif isinstance(nameArg, dict):
return nameArg.get("name", ""), nameArg.get("regex_enabled", False)
else:
raise ValueError("Invalid argument type for nameArg.")
# Model/occurrence helpers
def containsBodies(occurrence: adsk.fusion.Occurrence) -> bool:
"""
Given an occurrence, returns true if there are any B-Rep or Mesh bodies in
it (without recursion to child occurrences).
"""
return occurrence.bRepBodies.count + occurrence.component.meshBodies.count > 0
def getValidOccurrences(
occurrence: adsk.fusion.Occurrence,
) -> list[adsk.fusion.Occurrence]:
"""
Given an occurrence, this finds all child occurrences that contain either a
B-Rep or Mesh body. It is recursive, so it will find all occurrences at all
levels.
"""
result = []
for childOcc in occurrence.childOccurrences:
if containsBodies(childOcc):
result.append(childOcc)
result.extend(getValidOccurrences(childOcc))
return result
def getManufacturingModelBRepBodies(
manufacturingModel: adsk.cam.ManufacturingModel,
) -> list[adsk.fusion.BRepBody]:
"""Get the bodies found in manufacturing model"""
occurrences = getValidOccurrences(manufacturingModel.occurrence)
result = []
if len(occurrences) > 0:
for occurrence in occurrences:
bRepBodies = occurrence.bRepBodies
if bRepBodies.count > 0:
result.extend([bRepBodies.item(i) for i in range(bRepBodies.count)])
return result
def getHoleSegmentBottomEdge(
holeSegment: adsk.cam.RecognizedHoleSegment,
) -> adsk.fusion.BRepEdge | None:
"""
Get the bottom edge of the given hole segment, if:
- the segment is made by one face
- the hole is aligned with Z+ (bounding box checking)
Returns None otherwise.
"""
if len(holeSegment.faces) != 1:
# Expected a hole segment with a single face
return None
face = adsk.fusion.BRepFace.cast(holeSegment.faces[0])
if not face:
# Expected a BRep face
return None
faceEdges: adsk.fusion.BRepEdges = face.edges
if faceEdges.count != 2:
# Expected a hole segment with a single face made of two edges
return None
# Return the lower edge in Z
if faceEdges[0].boundingBox.maxPoint.z < faceEdges[1].boundingBox.maxPoint.z:
return faceEdges[0]
else:
return faceEdges[1]
def boundarySelectionNeedsFlipping(edge: adsk.fusion.BRepEdge) -> bool:
"""
Check if the selected edge should be flipped for geometry selection (to
mill inside counterbore).
Returns true if it needs to be flipped.
"""
Z_POS = adsk.core.Vector3D.create(0, 0, 1)
geometry = edge.geometry
if geometry:
circle = adsk.core.Circle3D.cast(geometry)
if circle:
return not circle.normal.isEqualTo(Z_POS)
return False
# Item-by-name helpers
def getNormalizedLowerCaseName(name: str) -> str:
"""
Normalize the given name into consistent lower-case unicode characters.
"""
return unicodedata.normalize("NFKD", name).casefold()
def areMatchingCaseInsensitiveNames(name1: str, name2: str) -> bool:
"""
Check if two names match case-insensitively.
"""
return getNormalizedLowerCaseName(name1) == getNormalizedLowerCaseName(name2)
def hasMatchingCaseInsensitiveName(
item: adsk.cam.OperationBase,
name: str,
regexEnabled: bool = False,
) -> bool:
"""
Check if the given item has a name that matches the given name.
This does a case-insensitive comparison for both regular expressions and
exact matches.
If regexEnabled is True, `name` is treated as a regular expression.
"""
normalizedItemName = getNormalizedLowerCaseName(item.name)
normalizedName = getNormalizedLowerCaseName(name)
if regexEnabled:
return re.search(normalizedName, normalizedItemName) is not None
else:
return normalizedItemName == normalizedName
def findMatchingParentsFromList(
parents: list[adsk.cam.Base],
parentName: str | None,
regexEnabled: bool,
) -> tuple[MissingObjectReason, list[adsk.cam.Base], bool]:
"""
Find all parent objects (Setups, Folders, Patterns) in the list that match
the given name.
If parentName is None, all objects are returned.
If regexEnabled is True, the name is treated as a regular expression.
Returns a tuple of:
- A MissingObjectReason indicating the reason for missing objects
(NONE if objects were found)
- A list of adsk.cam.Base objects that match the name
- A boolean indicating if the search was for all objects
(True if parentName is None or empty)
"""
parentNameIsProvided: bool = isNonEmptyString(parentName)
# Get the scope of parents that we want to search
selectingAllObjects: bool = False
if not parentNameIsProvided:
selectingAllObjects = True
parentName = ".*"
regexEnabled = True
# Get the matching parents
matchingParents: list[adsk.cam.Base] = [
parent
for parent in parents
if hasMatchingCaseInsensitiveName(parent, parentName, regexEnabled)
]
# Check that we found at least one parent according to the search criteria
reason = MissingObjectReason.NONE
if not regexEnabled and parentNameIsProvided:
# If exact name is provided, check we found it
if len(matchingParents) == 0:
reason = MissingObjectReason.NO_MATCHING_NAME
elif not selectingAllObjects and regexEnabled and parentNameIsProvided:
# If regex is enabled, check we found at least one parent
if len(matchingParents) == 0:
reason = MissingObjectReason.NO_MATCHING_REGEX
elif len(matchingParents) == 0:
# If no search criteria is provided, there were no parents to find
reason = MissingObjectReason.NO_OBJECTS
return reason, matchingParents, selectingAllObjects
def findMatchingSetups(
setupName: str | None,
regexEnabled: bool,
cam: adsk.cam.CAM,
errorMessage: str,
) -> tuple[bool, list[adsk.cam.Setup], bool]:
"""
Find all setups in the CAM document that match the given name.
If setupName is None, all setups are returned.
If regexEnabled is True, the name is treated as a regular expression.
Returns a tuple of:
- A boolean indicating if setups were found that match the name
- A list of adsk.cam.Setup objects that match the name
- A boolean indicating if the search was for all setups
(True if setupName is None or empty)
If no setups are found according to the search criteria, the function will
add an error message to the CAMFunctionContext.
"""
reason, matchingObjects, selectingAllObjects = findMatchingParentsFromList(
parents=cam.setups,
parentName=setupName,
regexEnabled=regexEnabled,
)
if reason == MissingObjectReason.NO_MATCHING_NAME:
CAMFunctionContext.fail(
message=errorMessage,
error=f"Setup '{setupName}' not found.",
)
elif reason == MissingObjectReason.NO_MATCHING_REGEX:
CAMFunctionContext.fail(
message=errorMessage,
error=f"No matching setups for the name: '{setupName}'.",
)
elif reason == MissingObjectReason.NO_OBJECTS:
CAMFunctionContext.fail(
message=errorMessage,
error="No setups found.",
)
return reason == MissingObjectReason.NONE, matchingObjects, selectingAllObjects
def findMatchingParents(
parentName: str | None,
regexEnabled: bool,
cam: adsk.cam.CAM,
errorMessage: str,
) -> tuple[bool, list[adsk.cam.Base], bool]:
"""
Find all objects in the CAM document that can contain operations
(Setups, Folders, Patterns), that match the given name.
If parentName is None, all parents are returned.
If regexEnabled is True, the name is treated as a regular expression.
Returns a tuple of:
- A boolean indicating if parents were found that match the name
- A list of adsk.cam.Base objects that match the name
- A boolean indicating if the search was for all parents
(True if parentName is None or empty)
If no parents are found according to the search criteria, the function will
add an error message to the CAMFunctionContext.
"""
# Get all objects that could contain an operation
objects: list[adsk.cam.Base] = []
def addNestedParentObjects(
parent: adsk.cam.CAMFolder | adsk.cam.CAMPattern | adsk.cam.Setup,
):
objects.append(parent)
for child in parent.children:
if (
child.objectType == adsk.cam.CAMFolder.classType()
or child.objectType == adsk.cam.CAMPattern.classType()
):
addNestedParentObjects(child)
for setup in cam.setups:
addNestedParentObjects(setup)
# Find the matching parents
reason, matchingParents, selectingAllParents = findMatchingParentsFromList(
parents=objects,
parentName=parentName,
regexEnabled=regexEnabled,
)
if reason == MissingObjectReason.NO_MATCHING_NAME:
CAMFunctionContext.fail(
message=errorMessage,
error=f"Parent '{parentName}' not found.",
)
elif reason == MissingObjectReason.NO_MATCHING_REGEX:
CAMFunctionContext.fail(
message=errorMessage,
error=f"No matching parents for the name: '{parentName}'.",
)
elif reason == MissingObjectReason.NO_OBJECTS:
CAMFunctionContext.fail(
message=errorMessage,
error="No parents found.",
)
return reason == MissingObjectReason.NONE, matchingParents, selectingAllParents
def findMatchingSetupsByType(
setupName: str | None,
regexEnabled: bool,
cam: adsk.cam.CAM,
operationType: adsk.cam.OperationTypes,
errorMessage: str,
) -> tuple[bool, list[adsk.cam.Setup], bool]:
"""
Find all setups in the CAM document that match the given name and operation
type.
If setupName is None, all setups are returned.
If regexEnabled is True, the name is treated as a regular expression.
Returns a tuple of:
- A boolean indicating if setups were found that match the name
- A list of adsk.cam.Setup objects that match the name
- A boolean indicating if the search was for all setups
(True if setupName is None or empty)
If no setups are found according to the search criteria, the function will
add an error message to the CAMFunctionContext.
"""
# Find all setups that match the name
foundSetups, setups, selectingAllSetups = findMatchingSetups(
setupName=setupName,
regexEnabled=regexEnabled,
cam=cam,
errorMessage=errorMessage,
)
if not foundSetups:
# If no setups were found, return False
return False, [], selectingAllSetups
# Check if the setups are the right type
def isCorrectOperationType(setup):
return setup.operationType == operationType
matchingSetups = [setup for setup in setups if isCorrectOperationType(setup)]
differentSetups = [setup for setup in setups if not isCorrectOperationType(setup)]
if (len(differentSetups) > 0) and not (regexEnabled or selectingAllSetups):
CAMFunctionContext.fail(
message=errorMessage,
error=f"Setup '{setups[0].name}' is not the correct type '{getOperationTypeString(operationType)}'.",
)
foundSetups = False
elif len(matchingSetups) == 0:
if selectingAllSetups:
CAMFunctionContext.fail(
message=errorMessage,
error=f"No {getOperationTypeString(operationType)} setups found.",
)
else:
CAMFunctionContext.fail(
message=errorMessage,
error=f"No matching {getOperationTypeString(operationType)} setups for the name: '{setupName}'.",
)
foundSetups = False
return foundSetups, matchingSetups, selectingAllSetups
def findMatchingAdditiveSetups(
setupName: str | None,
regexEnabled: bool,
cam: adsk.cam.CAM,
errorMessage: str,
) -> tuple[bool, list[adsk.cam.Setup]]:
"""
Find all additive setups in the CAM document that match the given name.
If setupName is None, all setups are returned.
If regexEnabled is True, the name is treated as a regular expression.
Returns a tuple of:
- A boolean indicating if additive setups were found that match the name
- A list of adsk.cam.Setup objects that match the name and are additive
setups
"""
foundSetups, matchingSetups, _ = findMatchingSetupsByType(
setupName=setupName,
regexEnabled=regexEnabled,
cam=cam,
operationType=adsk.cam.OperationTypes.AdditiveOperation,
errorMessage=errorMessage,
)
return foundSetups, matchingSetups
def findAdditiveArrangeInSetup(setup: adsk.cam.Setup):
operations = setup.operations
for i in range(operations.count):
operation = operations.item(i)
if operation.strategy == "additive_arrange":
return operation
return None
def searchChildrenForName(
children: adsk.cam.ChildOperationList,
name: str,
objectType: CAMObjectType,
parentTask: CAMFunctionTaskContext,
) -> tuple[adsk.cam.OperationBase | None, str | None]:
"""
Iterate through all child objects in the given collection and search for
an object with the given name.
"""
# If the children collection is empty, return None
if children.count == 0:
return None, None
with parentTask.makeSubTask(numberOfTasks=children.count) as task:
for i in range(children.count):
with task.makeSubTaskByIndex(index=i) as childTask:
child = children.item(i)
if child.objectType == adsk.cam.CAMFolder.classType():
if objectType.includesType(
CAMObjectType.FOLDER
) and hasMatchingCaseInsensitiveName(child, name):
return child, CAMObjectType.FOLDER.value
# Recursively search within folder children
found, typeStr = searchChildrenForName(
child.children, name, objectType, childTask
)
if found:
return found, typeStr
elif child.objectType == adsk.cam.CAMPattern.classType():
if objectType.includesType(
CAMObjectType.PATTERN
) and hasMatchingCaseInsensitiveName(child, name):
return child, CAMObjectType.PATTERN.value
# Recursively search within pattern children
found, typeStr = searchChildrenForName(
child.children, name, objectType, childTask
)
if found:
return found, typeStr
elif (
objectType.includesType(CAMObjectType.OPERATION)
and child.objectType == adsk.cam.Operation.classType()
and hasMatchingCaseInsensitiveName(child, name)
):
return child, CAMObjectType.OPERATION.value
return None, None
def findObjectByName(
cam: adsk.cam.CAM,
name: str,
object_type: str,
parentTask: CAMFunctionTaskContext,
) -> tuple[adsk.cam.OperationBase | None, str | None]:
"""
Returns the first object found in the CAM document with the given name and
a string of its type.
"""
objectType = CAMObjectType.ANY
if object_type is not None:
try:
objectType = CAMObjectType(object_type)
except ValueError:
CAMFunctionContext.warn(
message="Manufacturing object type is not recognized.",
warning=f"Unrecognized manufacturing object type: '{object_type}'. All manufacturing objects will be used for searching.",
)
if objectType.includesType(CAMObjectType.MANUFACTURING_MODEL):
manufacturingModels = cam.manufacturingModels
if manufacturingModels.count > 0:
for i in range(manufacturingModels.count):
manufacturingModel = manufacturingModels.item(i)
if hasMatchingCaseInsensitiveName(manufacturingModel, name):
return manufacturingModel, "manufacturing model"
if objectType != CAMObjectType.MANUFACTURING_MODEL:
# Only when we have a manufacturing model we don't want to look through the setups
setups = cam.setups
if setups.count == 0:
# If there are no setups, return None
return None, None
with parentTask.makeSubTask(numberOfTasks=setups.count) as task:
for i, setup in enumerate(setups):
with task.makeSubTaskByIndex(index=i) as setupTask:
if objectType.includesType(
CAMObjectType.SETUP
) and hasMatchingCaseInsensitiveName(setup, name):
return setup, "setup"
# Search within setup children for the named object if the setup name does not match
if objectType.canBeUnderSetup():
found, typeStr = searchChildrenForName(
setup.children, name, objectType, setupTask
)
if found:
return found, typeStr
return None, None
# CAM operation helpers
def createManufacturingModel(
cam: adsk.cam.CAM,
task: CAMFunctionTaskContext,
name: str = None,
) -> adsk.cam.ManufacturingModel:
"""
Create a new manufacturing model in the CAM document.
"""
manufacturingModels = cam.manufacturingModels
mmInput = manufacturingModels.createInput()
# Change the name if it is defined
if isNonEmptyString(name):
mmInput.name = name
# Async worker to create the manufacturing model
#
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> adsk.cam.ManufacturingModel:
return manufacturingModels.add(mmInput)
manufacturingModel = task.runAsyncWorker(worker=worker)
# Creating a new manufacturing model will schedule an idle task for invalidating
# any CAD reference cache. Since we need to use the manufacturing model right away,
# we need to ensure that the cache is valid before proceeding and not rely on
# adsk.doEvents() calls that might execute the idle task we need or not.
# Calling cam.checkValidity() will force the cache to be updated as necessary and
# the manufacturing model can be used as a CAD reference as normal.
cam.checkValidity()
# Check that the manufacturing model was created with the correct name
if isinstance(name, str) and name != manufacturingModel.name:
CAMFunctionContext.blockFutureExecution(
warning=f"Created manufacturing model named '{manufacturingModel.name}' (instead of '{name}')."
)
else:
CAMFunctionContext.succeed(
message=f"Created manufacturing model '{manufacturingModel.name}'."
)
return manufacturingModel
def getManufacturingModelByName(
cam: adsk.cam.CAM, name: str
) -> adsk.cam.ManufacturingModel | None:
"""
Returns the first manufacturing model matching the given name, or None.
"""
manufacturingModels = [
model
for model in cam.manufacturingModels
if hasMatchingCaseInsensitiveName(model, name)
]
return next(iter(manufacturingModels), None)
def setAdditiveSetup(
cam: adsk.cam.CAM,
additiveTechnology: str,
setupInput: adsk.cam.SetupInput,
task: CAMFunctionTaskContext,
manufacturingModelName: str = None,
) -> bool:
"""
Helper function to set machine, print setting, and model in the inputs for
a new additive setup.
"""
# Check the chosen technology, determine the machine and print setting
printSettingRegex = None
if additiveTechnology == "FFF":
machineModel = "Generic FFF Machine"
printSettingName = "PLA (Direct Drive)"
elif additiveTechnology == "DED":
machineModel = "Generic DED"
printSettingName = None
elif additiveTechnology == "MPBF":
machineModel = "Generic MPBF"
printSettingName = "MPBF 10 micron"
printSettingRegex = re.compile("MPBF 10 micron")
elif additiveTechnology == "MJF":
machineModel = "Jet Fusion 5000"
printSettingName = "HP - MJF"
elif additiveTechnology == "Binder Jetting":
machineModel = "Metal Jet S100"
printSettingName = "HP - BinderJet"
elif additiveTechnology == "SLA/DLP":
machineModel = "Generic SLA/DLP Printer"
printSettingName = "Autodesk Generic SLA"
elif additiveTechnology == "SLS":
machineModel = "Generic SLS Machine"
printSettingName = "Generic SLS"
else:
CAMFunctionContext.fail(
message="Error setting additive setup:",
error="Additive technology not specified or unknown.",
)
return False
# Find the machine model and print setting from Fusion library
camManager = adsk.cam.CAMManager.get()
libraryManager = camManager.libraryManager
machine = None
machineTask = task.makeSubTask(
message="Getting machine",
progressRange=(0, 70),
)
with machineTask:
machineLibrary = libraryManager.machineLibrary
machineUrl = machineLibrary.urlByLocation(
adsk.cam.LibraryLocations.Fusion360LibraryLocation
) ## .Fusion360LibraryLocation vs .LocalLibraryLocation etc.
# Get the machines from this location
#
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> list[adsk.cam.Machine]:
return machineLibrary.childMachines(machineUrl)
machines = machineTask.runAsyncWorker(worker=worker)
for m in machines:
if m.model == machineModel:
machine = m
break
if not machine:
CAMFunctionContext.fail(
message="Error setting additive setup:",
error=f"Machine '{machineModel}' not found.",
)
return False
# Find the print setting, if the machine model requires one
printSettingTask = task.makeSubTask(
message="Getting print setting",
progressRange=(70, 90),
)
with printSettingTask:
if printSettingName is not None:
printSettingLibrary = libraryManager.printSettingLibrary
printSettingUrl = printSettingLibrary.urlByLocation(
adsk.cam.LibraryLocations.Fusion360LibraryLocation
) ## .Fusion360LibraryLocation vs .LocalLibraryLocation etc.
# Get the print settings from this location
#
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> list[adsk.cam.PrintSetting]:
return printSettingLibrary.childPrintSettings(printSettingUrl)
printSettings = printSettingTask.runAsyncWorker(worker=worker)
printSetting = None
if printSettingRegex is None:
printSettingRegex = re.compile(f"^{re.escape(printSettingName)}$")
for ps in printSettings:
if printSettingRegex.search(ps.name):
printSetting = ps
break
if not printSetting:
CAMFunctionContext.fail(
message="Error setting additive setup:",
error=f"Print setting matching '{printSettingName}' not found.",
)
return False
else:
printSetting = None
# Determine the manufacturing model to use
# If name is given, use the first matching model with that name
# If not given, use the first available manufacturing model
# If we don't find a model, create one
manufacturingModels = cam.manufacturingModels
manufacturingModel = None
manufacturingModelTask = task.makeSubTask(
message="Getting manufacturing model",
progressRange=(90, 95),
)
with manufacturingModelTask:
if isNonEmptyString(manufacturingModelName):
manufacturingModel = getManufacturingModelByName(
cam=cam,
name=manufacturingModelName,
)
if not manufacturingModel:
CAMFunctionContext.fail(
message="Error setting additive setup:",
error=f"Manufacturing model '{manufacturingModelName}' not found.",
)
return False
elif manufacturingModels.count > 0:
manufacturingModel = manufacturingModels.item(0)
# Check that we have models to use for the models of the setup
if manufacturingModel is None:
validModel = (
containsBodies(cam.designRootOccurrence)
or len(getValidOccurrences(cam.designRootOccurrence)) > 0
)
else:
models = getValidOccurrences(manufacturingModel.occurrence)
validModel = len(models) > 0
manufacturingModelTask.updateUI()
if not validModel:
CAMFunctionContext.fail(
message="Error creating additive setup:",
error="No available components exist to select as the Models of the setup.",
)
return False
if not manufacturingModel:
manufacturingModel = createManufacturingModel(
cam=cam,
task=manufacturingModelTask,
)
models = getValidOccurrences(manufacturingModel.occurrence)
# Fill in the SetupInput with the inputs for an additive setup
with task.makeSubTask(progressRange=(95, 100)):
setupInput.machine = machine
setupInput.printSetting = printSetting
setupInput.models = models
# DED machines require the Stock solids to be set
if additiveTechnology == "DED":
setupInput.stockSolids = models
if printSetting is not None:
CAMFunctionContext.succeed(
message=f"Selected the '{machine.model}' machine and '{printSetting.name}' print setting for the new additive setup with {additiveTechnology} technology."
)
else:
CAMFunctionContext.succeed(
message=f"Selected the '{machine.model}' machine for the new additive setup with {additiveTechnology} technology."
)
return True
def setTurningSetup(
cam: adsk.cam.CAM,
modelInputMode: ModelInputMode,
setupInput: adsk.cam.SetupInput,
) -> bool:
"""
Helper function to set the inputs for a new turning setup.
"""
# Turning setups require a valid model
#
# If the model input mode is "none", then the model inputs do not need to
# be set but we still need to check that there is a valid model in the
# scene to create the setup.
#
# Otherwise, the ModelInput object would have set up the models on the setup
# input already.
if modelInputMode == ModelInputMode.NONE:
# If the model input mode is "none", then the model inputs do not need to
# be set but we still need to check that there is a valid model in the
# scene to create the setup.
if not containsBodies(cam.designRootOccurrence):
CAMFunctionContext.fail(
message="Error setting turning setup:",
error="No component has been added to the scene",
)
return False
else:
if len(setupInput.models) == 0:
CAMFunctionContext.fail(
message="Error setting turning setup:",
error="No models have been set for the setup",
)
return False
return True
def setCAMParameters(
inputParameters: dict[str, any],
camParameters: adsk.cam.CAMParameters,
task: CAMFunctionTaskContext,
) -> list[str]:
"""
Set the input parameters on the given CAM parameters collection.
Values in the input are expressions to be set.
Returns a list of parameters that raised an exception while setting.
"""
warningParams: list[str] = []
for i, (key, value) in enumerate(inputParameters.items()):
task.updateTaskProgress(i, len(inputParameters))
try:
camParameters.itemByName(key).expression = value
except Exception:
warningParams.append(key)
return warningParams
def createDrillOperation(
setup: adsk.cam.Setup,
tool: adsk.cam.Tool | None,
holeGroup: adsk.cam.RecognizedHoleGroup,
drillTipThroughBottom: bool,
) -> adsk.cam.Operation:
"""
Create a drilling operation for the given hole group using the specified tool.
If drillTipThroughBottom is True, the drill tip will go through the bottom of
the hole. Otherwise, it will stop at the bottom of the hole.
"""
# Select the hole faces to drill from the hole group, excluding flats
faces: list[adsk.fusion.BRepFace] = []
for hole in holeGroup:
for i in range(hole.segmentCount):
segment: adsk.cam.RecognizedHoleSegment = hole.segment(i)
if segment.holeSegmentType != adsk.cam.HoleSegmentType.HoleSegmentTypeFlat:
faces.extend(segment.faces)
# Create the operation input for drilling
input: adsk.cam.OperationInput = setup.operations.createInput("drill")
input.parameters.itemByName(
"drillTipThroughBottom"
).value.value = drillTipThroughBottom
input.parameters.itemByName("breakThroughDepth").expression = "2 mm"
input.parameters.itemByName("holeFaces").value.value = faces
if tool:
input.tool = tool
# Create and return the drilling operation
op: adsk.cam.Operation = setup.operations.add(input)
return op
def createCounterboreMillOperation(
setup: adsk.cam.Setup,
tool: adsk.cam.Tool | None,
holeGroup: adsk.cam.RecognizedHoleGroup,
) -> adsk.cam.Operation:
"""
Create a counterbore milling operation for the given hole group using the
specified tool.
"""
# Select the counterbore bottom edge to mill
edges: list[adsk.fusion.BRepEdge] = []
for hole in holeGroup:
# Find the first cylinder (skipping cone/chamfer)
seg0: adsk.cam.RecognizedHoleSegment = hole.segment(0)
seg1: adsk.cam.RecognizedHoleSegment = hole.segment(1)
if seg0.holeSegmentType == adsk.cam.HoleSegmentType.HoleSegmentTypeCylinder:
counterboreSegment: adsk.cam.RecognizedHoleSegment = seg0
elif seg1.holeSegmentType == adsk.cam.HoleSegmentType.HoleSegmentTypeCylinder:
counterboreSegment = seg1
else:
continue
edge: adsk.fusion.BRepEdge | None = getHoleSegmentBottomEdge(counterboreSegment)
if edge:
edges.append(edge)
# Create the operation input for counterbore milling
input: adsk.cam.OperationInput = setup.operations.createInput("contour2d")
input.parameters.itemByName("doLeadIn").expression = "false"
input.parameters.itemByName("doRamp").expression = "true"
input.parameters.itemByName("rampAngle").expression = "2 deg"
input.parameters.itemByName("exit_verticalRadius").expression = "0 mm"
input.parameters.itemByName("exit_radius").expression = "0 mm"
if tool:
input.tool = tool
# Add each edge to the contours chains as separate selections, since they are owned by different holes
holeSelection: adsk.cam.CadContours2dParameterValue = input.parameters.itemByName(
"contours"
).value
chains: adsk.cam.CurveSelections = holeSelection.getCurveSelections()
for edge in edges:
chain: adsk.cam.ChainSelection = chains.createNewChainSelection()
chain.isReverted = boundarySelectionNeedsFlipping(edge)
chain.inputGeometry = [edge]
holeSelection.applyCurveSelections(chains)
# Create and return the milling operation
op: adsk.cam.Operation = setup.operations.add(input)
return op
def createChamferOperation(
setup: adsk.cam.Setup,
tool: adsk.cam.Tool | None,
holeGroup: adsk.cam.RecognizedHoleGroup,
) -> adsk.cam.Operation:
"""
Create an operation for the top chamfer of the given hole group.
"""
# Select the counterbore chamfer's bottom edge to mill
edges: list[adsk.fusion.BRepEdge] = []
for hole in holeGroup:
# Index 0 is the chamfer segment
firstSegment: adsk.cam.RecognizedHoleSegment = hole.segment(0)
edge: adsk.fusion.BRepEdge | None = getHoleSegmentBottomEdge(firstSegment)
if edge:
edges.append(edge)
# Create the operation input for chamfer milling
input: adsk.cam.OperationInput = setup.operations.createInput("chamfer2d")
input.parameters.itemByName("chamferClearance").expression = "0 mm"
input.parameters.itemByName("chamferTipOffset").expression = "1 mm"
input.parameters.itemByName("entry_distance").expression = "5 mm"
if tool:
input.tool = tool
# Add each edge to the contours chains as separate selections, since they are owned by different holes
holeSelection: adsk.cam.CadContours2dParameterValue = input.parameters.itemByName(
"contours"
).value
chains: adsk.cam.CurveSelections = holeSelection.getCurveSelections()
for edge in edges:
chain: adsk.cam.ChainSelection = chains.createNewChainSelection()
chain.isReverted = True
chain.inputGeometry = [edge]
holeSelection.applyCurveSelections(chains)
# Add to the setup
op: adsk.cam.Operation = setup.operations.add(input)
return op
def regenerateToolpaths(
cam: adsk.cam.CAM,
task: CAMFunctionTaskContext,
operations: list[adsk.cam.Operation] | None = None,
skipValid: bool = True,
) -> list[adsk.cam.Operation]:
"""
Regenerate the toolpath(s) according to the provided arguments.
:param operations: If provided, only these operations will be regenerated.
:param skipValid: If True, only operations that are not valid will be regenerated.
If False, all operations will be regenerated.
Only applicable if operations is None.
Returns a list of operations that were regenerated.
"""
generatedOperations: list[adsk.cam.Operation] = []
if operations is None:
future: adsk.cam.GenerateToolpathFuture = cam.generateAllToolpaths(skipValid)
else:
future: adsk.cam.GenerateToolpathFuture = cam.generateToolpath(
adsk.core.ObjectCollection.createWithArray(operations),
)
try:
generatedOperations.extend(future.operations)
while not future.isGenerationCompleted:
task.updateTaskProgress(future.numberOfCompletedTasks, future.numberOfTasks)
time.sleep(0.1)
except Exception as e:
# Gracefully handle "Generation not started" errors, re-raise other exceptions
if "Generation not started" not in str(e):
raise e
# Return the generated operations
return generatedOperations
def selectOperations(
ui: adsk.core.UserInterface,
operations: list[adsk.cam.Operation],
task: CAMFunctionTaskContext,
):
"""
Select the given operations in the CAM workspace.
"""
ui.activeSelections.clear()
if len(operations) == 0:
return
with task.makeSubTask(numberOfTasks=len(operations)) as selectTask:
for i, operation in enumerate(operations):
selectTask.updateTaskProgress(i, len(operations))
ui.activeSelections.add(operation)
# Tool Library helpers
def getToolLibrary(
toolLibraryDefinition: ToolLibraryDefinition,
task: CAMFunctionTaskContext,
) -> adsk.cam.ToolLibrary | None:
"""
Return a tool library from a tool library definition.
"""
if not isNonEmptyString(toolLibraryDefinition.name):
return None
locationsToSearch: list[adsk.cam.LibraryLocations] = []
libraryLocation = adsk.cam.LibraryLocations.LocalLibraryLocation
match toolLibraryDefinition.type:
case ToolLibraryType.SAMPLES:
locationsToSearch.append(adsk.cam.LibraryLocations.Fusion360LibraryLocation)
case ToolLibraryType.LOCAL:
locationsToSearch.append(adsk.cam.LibraryLocations.LocalLibraryLocation)
case ToolLibraryType.CLOUD:
locationsToSearch.append(adsk.cam.LibraryLocations.CloudLibraryLocation)
case ToolLibraryType.ALL:
# Search both local and Sample libraries in that order
locationsToSearch.append(adsk.cam.LibraryLocations.LocalLibraryLocation)
locationsToSearch.append(adsk.cam.LibraryLocations.Fusion360LibraryLocation)
locationsToSearch.append(adsk.cam.LibraryLocations.CloudLibraryLocation)
case ToolLibraryType.DOCUMENT:
# We should have already have grabbed the document library and set it in the tool library definition
return toolLibraryDefinition.toolLibrary
case _:
return None
if len(locationsToSearch) == 0:
return None
camManager = adsk.cam.CAMManager.get()
libraryManager: adsk.cam.CAMLibraryManager = camManager.libraryManager
toolLibraries: adsk.cam.ToolLibraries = libraryManager.toolLibraries
with task.makeSubTask(numberOfTasks=len(locationsToSearch)) as searchTask:
for i, libraryLocation in enumerate(locationsToSearch):
with searchTask.makeSubTaskByIndex(index=i) as libraryTask:
try:
libraryFolder = toolLibraries.urlByLocation(libraryLocation)
url = libraryFolder.join(f"{toolLibraryDefinition.name}.json")
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> adsk.cam.ToolLibrary:
return toolLibraries.toolLibraryAtURL(url)
toolLibrary: adsk.cam.ToolLibrary = libraryTask.runAsyncWorker(
worker=worker
)
if toolLibrary:
return toolLibrary
except Exception:
continue
return None
def getToolsByQueryForAllLibraries(
toolLibraryDefinition: ToolLibraryDefinition | None,
addCriteriaToQueryFunction: Callable[[adsk.cam.ToolQuery], None],
task: CAMFunctionTaskContext,
) -> list[adsk.cam.Tool]:
"""
Get the tools that fulfill the given criteria searching through all types of libraries.
The order of libraries is:
- Local Library
- Fusion Sample Tool Libraries
- Document Library
"""
for libraryType in TOOL_LIBRARY_SEARCH_PRIORITY:
possibleToolLibraryDefinition = ToolLibraryDefinition(
name=toolLibraryDefinition.name
if toolLibraryDefinition and isNonEmptyString(toolLibraryDefinition.name)
else None,
type=libraryType,
toolLibrary=toolLibraryDefinition.toolLibrary
if toolLibraryDefinition
else None,
)
tools = getToolsByQuery(
toolLibraryDefinition=possibleToolLibraryDefinition,
addCriteriaToQueryFunction=addCriteriaToQueryFunction,
task=task,
)
if len(tools) > 0:
return tools
return []
def getToolsByQuery(
toolLibraryDefinition: ToolLibraryDefinition | None,
addCriteriaToQueryFunction: Callable[[adsk.cam.ToolQuery], None],
task: CAMFunctionTaskContext,
) -> list[adsk.cam.Tool]:
"""
Return the tools that match the given criteria from the specified tool definition.
"""
# Define the query to find the tool by name
query: adsk.cam.ToolQuery | None = None
if toolLibraryDefinition:
if toolLibraryDefinition.toolLibrary:
query = toolLibraryDefinition.toolLibrary.createQuery()
elif isNonEmptyString(toolLibraryDefinition.name):
# If a tool library name is provided, try to get the library
toolLibrary = getToolLibrary(
toolLibraryDefinition=toolLibraryDefinition,
task=task,
)
if toolLibrary is None:
return []
query = toolLibrary.createQuery()
else:
camManager = adsk.cam.CAMManager.get()
libraryManager: adsk.cam.CAMLibraryManager = camManager.libraryManager
toolLibraries: adsk.cam.ToolLibraries = libraryManager.toolLibraries
match toolLibraryDefinition.type:
case ToolLibraryType.ALL:
return getToolsByQueryForAllLibraries(
toolLibraryDefinition=toolLibraryDefinition,
addCriteriaToQueryFunction=addCriteriaToQueryFunction,
task=task,
)
case ToolLibraryType.SAMPLES:
query = toolLibraries.createQuery(
adsk.cam.LibraryLocations.Fusion360LibraryLocation
)
case ToolLibraryType.LOCAL:
query = toolLibraries.createQuery(
adsk.cam.LibraryLocations.LocalLibraryLocation
)
case ToolLibraryType.CLOUD:
query = toolLibraries.createQuery(
adsk.cam.LibraryLocations.CloudLibraryLocation
)
case ToolLibraryType.DOCUMENT:
if toolLibraryDefinition.toolLibrary is not None:
# We should have already grabbed the document library and set it in the tool definition
query = toolLibraryDefinition.toolLibrary.createQuery()
else:
# If no tool library definition is provided, search all tool libraries
return getToolsByQueryForAllLibraries(
toolLibraryDefinition=toolLibraryDefinition,
addCriteriaToQueryFunction=addCriteriaToQueryFunction,
task=task,
)
if query is None:
return []
addCriteriaToQueryFunction(query)
# Get the query results
#
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> list[adsk.cam.ToolQueryResult]:
try:
return query.execute()
except Exception:
return []
results = task.runAsyncWorker(worker=worker)
# Get the tools from the query
tools: list[adsk.cam.Tool] = [result.tool for result in results]
return tools
def getToolByName(
toolDefinition: ToolDefinition,
task: CAMFunctionTaskContext,
) -> adsk.cam.Tool | None:
"""
Return a tool from the given tool definition by name.
"""
if not isNonEmptyString(toolDefinition.name):
# If no name is specified, return None
return None
def addCriteriaToQueryFunction(query: adsk.cam.ToolQuery):
"""
Function to add criteria to the query.
This is passed as a parameter to getToolsByQuery.
"""
# Add criteria for tool description (name)
query.criteria.add(
"tool_description",
adsk.core.ValueInput.createByString(toolDefinition.name),
)
# Get the tools from the query
tools: list[adsk.cam.Tool] = getToolsByQuery(
toolLibraryDefinition=toolDefinition.toolLibraryDefinition,
addCriteriaToQueryFunction=addCriteriaToQueryFunction,
task=task,
)
# Return the first tool that matches the name
if len(tools) > 0:
return tools[0]
# If no tool was found, return None
return None
def getFilteredTools(
toolLibraryDefinition: ToolLibraryDefinition | None,
task: CAMFunctionTaskContext,
toolType: str | None = None,
minDiameter: float | None = None,
maxDiameter: float | None = None,
) -> list[adsk.cam.Tool]:
"""
Return a list of tools from the given tool definition that match the given
criteria. If no criteria are provided, all tools are returned.
"""
def addCriteriaToQueryFunction(query: adsk.cam.ToolQuery):
"""
Function to add criteria to the query.
This is passed as a parameter to getToolsByQuery.
"""
# Add criteria for tool type, if specified
if toolType is not None:
query.criteria.add(
"tool_type", adsk.core.ValueInput.createByString(toolType)
)
# Add criteria for diameter, if specified
if minDiameter is not None:
query.criteria.add(
"tool_diameter.min", adsk.core.ValueInput.createByReal(minDiameter)
)
if maxDiameter is not None:
query.criteria.add(
"tool_diameter.max", adsk.core.ValueInput.createByReal(maxDiameter)
)
return getToolsByQuery(
toolLibraryDefinition=toolLibraryDefinition,
addCriteriaToQueryFunction=addCriteriaToQueryFunction,
task=task,
)
def getToolsToMakeHole(
hole: adsk.cam.RecognizedHole,
holeAttributes: HoleAttributes,
drillingToolLibraryDefinition: ToolLibraryDefinition | None,
millingToolLibraryDefinition: ToolLibraryDefinition | None,
task: CAMFunctionTaskContext,
) -> tuple[
adsk.cam.Tool | None,
adsk.cam.Tool | None,
adsk.cam.Tool | None,
]:
"""
Return a set of tools to be used to make the given hole.
The tools are returned in the following order:
- Drill tool (for drilling the hole)
- Counterbore tool (for counterboring the hole, if applicable)
- Chamfer tool (for chamfering the hole, if applicable)
"""
# Ratios of the counterbore diameter to define minimum and maximum tools to cut the counterbore
COUNTERBORE_TOOL_DIAMETER_RATIO_MIN: float = 1.00
COUNTERBORE_TOOL_DIAMETER_RATIO_MAX: float = 1.25
drillTool: adsk.cam.Tool | None = None
counterboreTool: adsk.cam.Tool | None = None
chamferTool: adsk.cam.Tool | None = None
# Get features of the hole and what tools are needed
drillingDiameter: float = -1.0
counterboreRadius: float = -1.0
needChamfer: bool = False
if holeAttributes & HoleAttributes.SIMPLE:
if holeAttributes & HoleAttributes.CHAMFER:
# Simple hole with top chamfer
drillingDiameter = hole.segment(1).bottomDiameter
needChamfer = True
else:
# Simple hole
drillingDiameter = hole.segment(0).bottomDiameter
elif holeAttributes & HoleAttributes.COUNTERBORE:
if holeAttributes & HoleAttributes.CHAMFER:
# Counterbore hole with top chamfer
drillingDiameter = hole.segment(3).bottomDiameter
counterboreRadius = hole.segment(1).bottomDiameter / 2.0
needChamfer = True
else:
# Counterbore hole
drillingDiameter = hole.segment(2).bottomDiameter
counterboreRadius = hole.segment(0).bottomDiameter / 2.0
# Get drill tool matching the drilling diameter
with task.makeSubTask(progressRange=(0, 40)) as drillToolTask:
if drillingDiameter > 0:
drillingTools: list[adsk.cam.Tool] = getFilteredTools(
toolLibraryDefinition=drillingToolLibraryDefinition,
toolType="drill",
minDiameter=drillingDiameter,
maxDiameter=drillingDiameter,
task=drillToolTask,
)
drillTool = next(iter(drillingTools), None)
# Get counterbore tool matching the counterbore diameter
with task.makeSubTask(progressRange=(40, 80)) as counterboreToolTask:
if counterboreRadius > 0:
minToolDiameter: float = (
counterboreRadius * COUNTERBORE_TOOL_DIAMETER_RATIO_MIN
)
maxToolDiameter: float = (
counterboreRadius * COUNTERBORE_TOOL_DIAMETER_RATIO_MAX
)
counterboreTools: list[adsk.cam.Tool] = getFilteredTools(
toolLibraryDefinition=millingToolLibraryDefinition,
toolType="flat end mill",
minDiameter=minToolDiameter,
maxDiameter=maxToolDiameter,
task=counterboreToolTask,
)
counterboreTool = next(iter(counterboreTools), None)
# Get chamfer tool if required
with task.makeSubTask(progressRange=(80, 100)) as chamferToolTask:
if needChamfer:
chamferToolDefinition = ToolDefinition(
name="6mm x 45 Engraving Tool",
toolLibraryDefinition=ToolLibraryDefinition(
toolLibrary=millingToolLibraryDefinition.toolLibrary
if millingToolLibraryDefinition
else None,
name=millingToolLibraryDefinition.name
if millingToolLibraryDefinition
else None,
type=millingToolLibraryDefinition.type
if millingToolLibraryDefinition
else None,
),
)
chamferTool = getToolByName(
toolDefinition=chamferToolDefinition,
task=chamferToolTask,
)
# Return the tools in the order: drilling, counterbore, chamfer
return drillTool, counterboreTool, chamferTool
# Hole and Pocket Recognition helpers
def isCircularPocket(pocket: adsk.cam.RecognizedPocket) -> bool:
"""Returns true if this is a circular pocket (= a hole) made of boundaries with circular segments only"""
isCircleFound: bool = False
for boundary in pocket.boundaries:
for segment in boundary:
if segment.classType() == adsk.core.Circle3D.classType():
isCircleFound = True
else:
return False
return isCircleFound
def recognizePockets(
collections: list[adsk.fusion.BRepBody | adsk.fusion.Occurrence],
parentTask: CAMFunctionTaskContext,
) -> list[adsk.cam.RecognizedPocket]:
"""
Recognize pockets (from top Z view) in bodies.
Return a list of recognized pockets.
"""
POCKET_SEARCH_VECTOR = adsk.core.Vector3D.create(0, 0, -1)
bodies: list[adsk.fusion.BRepBody] = []
for collection in collections:
if isinstance(collection, adsk.fusion.BRepBody):
bodies.append(collection)
elif isinstance(collection, adsk.fusion.Occurrence):
# If the occurrence has a BRepBody, add it to the list
bRepBodies: adsk.fusion.BRepBodies = collection.bRepBodies
bodies.extend([bRepBodies.item(i) for i in range(bRepBodies.count)])
pockets: list[adsk.cam.RecognizedPocket] = []
if len(bodies) == 0:
return pockets
with parentTask.makeSubTask(numberOfTasks=len(bodies)) as task:
for i, body in enumerate(bodies):
with task.makeSubTaskByIndex(index=i) as bodyTask:
# Recognize pockets in the body using the search vector
#
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> adsk.cam.RecognizedPockets:
return adsk.cam.RecognizedPocket.recognizePockets(
body,
POCKET_SEARCH_VECTOR,
)
recognizedPockets = bodyTask.runAsyncWorker(worker=worker)
for pocket in recognizedPockets:
if isCircularPocket(pocket):
pass # ignore circular pockets
else:
pockets.append(pocket)
return pockets
def recognizeHoleGroups(
bodies: list[adsk.fusion.BRepBody],
task: CAMFunctionTaskContext,
) -> adsk.cam.RecognizedHoleGroups:
"""
Recognize hole groups in 'bodies'.
Returns the collection of recognized hole groups.
"""
# This can be a long operation, so we run it asynchronously and
# indeterminately increment the progress bar
async def worker() -> adsk.cam.RecognizedHoleGroups:
recognizedHolesInput = adsk.cam.RecognizedHolesInput.create()
return adsk.cam.RecognizedHoleGroup.recognizeHoleGroupsWithInput(
bodies=bodies,
input=recognizedHolesInput,
)
return task.runAsyncWorker(worker=worker)
def classifyHoleGroup(
holeGroup: adsk.cam.RecognizedHoleGroup,
) -> HoleAttributes:
"""
Examine the hole group and return a set of attributes that describe its
profile and depth.
Returns HoleAttributes(0) if the profile is not recognized.
"""
referenceHole: adsk.cam.RecognizedHole = holeGroup.item(0)
includeHole: bool = True
if referenceHole.isThrough:
attributes: HoleAttributes = HoleAttributes.THROUGH
match referenceHole.segmentCount:
case 1: # Simple through hole
attributes |= HoleAttributes.SIMPLE
case 2: # Simple through hole with top chamfer
attributes |= HoleAttributes.SIMPLE | HoleAttributes.CHAMFER
case 3: # Counterbore through hole
attributes |= HoleAttributes.COUNTERBORE
case 4: # Counterbore through hole with top chamfer
attributes |= HoleAttributes.COUNTERBORE | HoleAttributes.CHAMFER
case _:
includeHole = False # Unrecognized hole profile
else:
attributes: HoleAttributes = HoleAttributes.BLIND
match referenceHole.segmentCount:
case 2: # Simple blind hole
attributes |= HoleAttributes.SIMPLE
case 3: # Simple blind hole with top chamfer
attributes |= HoleAttributes.SIMPLE | HoleAttributes.CHAMFER
case 4: # Counterbore blind hole
attributes |= HoleAttributes.COUNTERBORE
case 5: # Counterbore blind hole with top chamfer
attributes |= HoleAttributes.COUNTERBORE | HoleAttributes.CHAMFER
case _:
includeHole = False # Unrecognized hole profile
return attributes if includeHole else HoleAttributes(0)
def classifyHoleGroups(
holeGroups: adsk.cam.RecognizedHoleGroups,
task: CAMFunctionTaskContext,
) -> list[tuple[adsk.cam.RecognizedHoleGroup, HoleAttributes]]:
"""
Classify the hole groups and return a list of tuples containing the hole
group and its attributes.
Each tuple contains:
- adsk.cam.RecognizedHoleGroup: The recognized hole group
- HoleAttributes: The attributes of the hole group (SIMPLE, COUNTERBORE,
THROUGH, BLIND, CHAMFER)
"""
classifiedHoles: list[tuple[adsk.cam.RecognizedHoleGroup, HoleAttributes]] = []
for i, holeGroup in enumerate(holeGroups):
task.updateTaskProgress(i, holeGroups.count)
attributes: HoleAttributes = classifyHoleGroup(holeGroup)
if attributes != HoleAttributes(0):
classifiedHoles.append((holeGroup, attributes))
return classifiedHoles
def getAppearanceByColorName(
design: adsk.fusion.Design,
name: str,
color_code: tuple[int],
) -> adsk.core.Appearance | None:
"""Return a Fusion appearance from a color name. color_code is a tuple that defines the RGB code (0-255). Returns None if appearance cannot be created because of invalid color"""
# Look for the color
appearance: adsk.core.Appearance = design.appearances.itemByName(name)
if not appearance:
# first check if the color is valid
try:
color = adsk.core.Color.create(
color_code[0], color_code[1], color_code[2], 255
)
except Exception:
return None
# get the application
app: adsk.core.Application = design.parentDocument.parent
# Get the existing Red appearance.
fusionMaterials: adsk.core.MaterialLibrary = app.materialLibraries.itemById(
"BA5EE55E-9982-449B-9D66-9F036540E140"
)
redAppearance: adsk.core.Appearance = fusionMaterials.appearances.itemById(
"Prism-093"
)
# Copy it to the design, giving it a new name.
appearance: adsk.core.Appearance = design.appearances.addByCopy(
redAppearance, name
)
# Change the color of the default appearance to the provided one.
theColor: adsk.core.ColorProperty = appearance.appearanceProperties.itemById(
"opaque_albedo"
)
theColor.value = color
return appearance
def colorFaces(
app: adsk.core.Application,
faces: list[adsk.fusion.BRepFace],
appearance: adsk.core.Appearance,
task: CAMFunctionTaskContext,
):
"""
Color the given faces with the given appearance.
"""
if len(faces) == 0:
# If no faces are provided, return early
return
for i, face in enumerate(faces):
task.updateTaskProgress(i, len(faces))
# Set the appearance of the face
face.appearance = appearance
# Refresh the UI to apply the changes
app.activeViewport.refresh()
# Milling holes in a setup
def millHoleGroup(
setup: adsk.cam.Setup,
holeGroup: adsk.cam.RecognizedHoleGroup,
holeAttributes: HoleAttributes,
drillingToolLibraryDefinition: ToolLibraryDefinition | None,
millingToolLibraryDefinition: ToolLibraryDefinition | None,
task: CAMFunctionTaskContext,
) -> list[adsk.cam.Operation]:
"""
Create operations in the given setup for the holes in the given group.
Operations will be created based on the hole attributes.
Tools will be picked automatically from the given tool libraries.
"""
assert holeAttributes & (HoleAttributes.SIMPLE | HoleAttributes.COUNTERBORE)
referenceHole: adsk.cam.RecognizedHole = holeGroup.item(0)
with task.makeSubTask(message="Finding tools", progressRange=(0, 40)) as toolTask:
drill, counterbore, chamfer = getToolsToMakeHole(
hole=referenceHole,
holeAttributes=holeAttributes,
drillingToolLibraryDefinition=drillingToolLibraryDefinition,
millingToolLibraryDefinition=millingToolLibraryDefinition,
task=toolTask,
)
operations: list[adsk.cam.Operation] = []
numberOfOperations: int = sum(
[
True, # Always drill the hole
bool(holeAttributes & HoleAttributes.COUNTERBORE),
bool(holeAttributes & HoleAttributes.CHAMFER),
]
)
opTask = task.makeSubTask(
message="Creating operations",
progressRange=(40, 100),
numberOfTasks=numberOfOperations,
)
with opTask:
# Drill hole at the bottom
opTask.updateTaskProgress(len(operations), numberOfOperations)
drillOp: adsk.cam.Operation = createDrillOperation(
setup=setup,
tool=drill,
holeGroup=holeGroup,
drillTipThroughBottom=bool(holeAttributes & HoleAttributes.THROUGH),
)
operations.append(drillOp)
if holeAttributes & HoleAttributes.COUNTERBORE:
# If the hole is a counterbore, create a counterbore operation for the
# larger diameter hole above it
opTask.updateTaskProgress(len(operations), numberOfOperations)
counterboreOp: adsk.cam.Operation = createCounterboreMillOperation(
setup=setup,
tool=counterbore,
holeGroup=holeGroup,
)
operations.append(counterboreOp)
if holeAttributes & HoleAttributes.CHAMFER:
# If the hole has a top chamfer, create a chamfer operation
opTask.updateTaskProgress(len(operations), numberOfOperations)
chamferOp: adsk.cam.Operation = createChamferOperation(
setup=setup,
tool=chamfer,
holeGroup=holeGroup,
)
operations.append(chamferOp)
return operations
def sketchLine(
sketch: adsk.fusion.Sketch,
startPoint: adsk.core.Point3D,
endPoint: adsk.core.Point3D,
) -> adsk.fusion.SketchLine:
"""Sketch a straight line based on the starting and ending points"""
lines: adsk.fusion.SketchLines = sketch.sketchCurves.sketchLines
line: adsk.fusion.SketchLine = lines.addByTwoPoints(startPoint, endPoint)
return line
def sketchTwoPointArc(
sketch: adsk.fusion.Sketch,
centerPoint: adsk.core.Point3D,
startPoint: adsk.core.Point3D,
sweepAngle: float,
normal: adsk.core.Vector3D,
) -> adsk.fusion.SketchArc:
"""Sketch an arc based on center, radius and sweep angle"""
arcs: adsk.fusion.SketchArcs = sketch.sketchCurves.sketchArcs
arc: adsk.fusion.SketchArc = arcs.addByCenterStartSweep(
centerPoint, startPoint, sweepAngle
)
arcNormal: adsk.core.Vector3D = arc.geometry.normal
# Check whether the arc is drawn in the right direction
if (
not arcNormal.z - normal.z < 0.000001
and arcNormal.y - normal.y < 0.000001
and arcNormal.x - normal.x < 0.000001
):
arc.deleteMe()
arc = arcs.addByCenterStartSweep(centerPoint, startPoint, -sweepAngle)
return arc
def sketchCircles(
sketch: adsk.fusion.Sketch, centerPoint: adsk.core.Point3D, radius: float
) -> adsk.fusion.SketchCircle:
"""Create a circle based on the points"""
circles: adsk.fusion.SketchCircles = sketch.sketchCurves.sketchCircles
circle: adsk.fusion.SketchCircle = circles.addByCenterRadius(centerPoint, radius)
return circle
def sketchNurbsCurve(sketch: adsk.fusion.Sketch, nurbsCurve: adsk.core.NurbsCurve3D):
"""Sketch a spline"""
splines = sketch.sketchCurves.sketchFixedSplines.addByNurbsCurve(nurbsCurve)
return splines
def drawSketchCurves(
sketch: adsk.fusion.Sketch, boundary: list[adsk.core.Curve3D]
) -> None:
"""Create a sketch from given pocket boundary or island"""
for segment in boundary:
# Cast to get the actual segment type (casting returns None if the wrong object is passed)
line3D = adsk.core.Line3D.cast(segment)
arc3D = adsk.core.Arc3D.cast(segment)
circle3D = adsk.core.Circle3D.cast(segment)
ellipse3D = adsk.core.Ellipse3D.cast(segment)
nurbsCurve3D = adsk.core.NurbsCurve3D.cast(segment)
ellipticalArc3D = adsk.core.EllipticalArc3D.cast(segment)
if line3D:
startPoint: adsk.core.Point3D = line3D.startPoint
endPoint: adsk.core.Point3D = line3D.endPoint
sketchLine(sketch, startPoint, endPoint)
elif arc3D:
startPoint: adsk.core.Point3D = arc3D.startPoint
centerPoint: adsk.core.Point3D = arc3D.center
sweepAngle: float = arc3D.endAngle
normal: adsk.core.Vector3D = arc3D.normal
sketchTwoPointArc(sketch, centerPoint, startPoint, sweepAngle, normal)
elif circle3D:
centerPoint: adsk.core.Point3D = circle3D.center
radius: float = circle3D.radius
sketchCircles(sketch, centerPoint, radius)
elif ellipse3D:
nurbsCurve = ellipse3D.asNurbsCurve
sketchNurbsCurve(sketch, nurbsCurve)
elif nurbsCurve3D:
sketchNurbsCurve(sketch, nurbsCurve3D)
elif ellipticalArc3D:
nurbsCurve = ellipticalArc3D.asNurbsCurve
sketchNurbsCurve(sketch, nurbsCurve)
else:
classType = segment.classType()
raise Exception("Unsupported curve type: " + classType)
def sketchPocketBoundary(
component: adsk.fusion.Component, pocket: adsk.cam.RecognizedPocket
):
"""Create a sketch from the recognized pocket boundary under a component tree"""
sketches = component.sketches
sketch: adsk.fusion.Sketch = sketches.add(component.xYConstructionPlane)
sketch.isVisible = False
sketch.isComputeDeferred = True
for boundary in pocket.boundaries:
drawSketchCurves(sketch, boundary)
for island in pocket.islands:
drawSketchCurves(sketch, island)
sketch.isComputeDeferred = False
return sketch
def millPocket(
operation_type: str,
pocket: adsk.cam.RecognizedPocket,
setup: adsk.cam.Setup,
tool: adsk.cam.Tool,
sketch: adsk.fusion.Sketch,
):
input: adsk.cam.OperationInput = setup.operations.createInput(operation_type)
if tool:
input.tool = tool
input.parameters.itemByName("doMultipleDepths").expression = "true"
input.parameters.itemByName("maximumStepdown").expression = (
str(round(pocket.depth / 2, 3) * 10) + " mm"
) # Divide total height by 2 to get 2 passes
input.parameters.itemByName("topHeight_mode").expression = "'from contour'"
input.parameters.itemByName("topHeight_offset").expression = (
str(pocket.depth * 10) + " mm"
)
# Apply the sketch boundary to the operation input
pocketSelection: adsk.cam.CadContours2dParameterValue = input.parameters.itemByName(
"pockets"
).value
chains: adsk.cam.CurveSelections = pocketSelection.getCurveSelections()
chain: adsk.cam.SketchSelection = chains.createNewSketchSelection()
chain.inputGeometry = [sketch]
# chain.loopType = adsk.cam.LoopTypes.OnlyOutsideLoops
chain.loopType = adsk.cam.LoopTypes.AllLoops
chain.sideType = adsk.cam.SideTypes.AlwaysInsideSideType
# chain.sideType = adsk.cam.SideTypes.StartInsideSideType
pocketSelection.applyCurveSelections(chains)
# Add to the setup
op: adsk.cam.OperationBase = setup.operations.add(input)
return [op]
def millPockets(
operation_type: str,
pockets: list[adsk.cam.RecognizedPocket],
setup: adsk.cam.Setup,
tool: adsk.cam.Tool,
task: CAMFunctionTaskContext,
):
"""create operation in the given pockets, in a given setup with given tool"""
modelItem: adsk.fusion.BRepBody | adsk.fusion.Occurrence = setup.models.item(0)
component = None
if type(modelItem) is adsk.fusion.BRepBody:
component = modelItem.parentComponent
elif type(modelItem) is adsk.fusion.Occurrence:
component = modelItem.component
else:
CAMFunctionContext.warn(
message="Warning milling pockets:",
warning="Component is not a BRepBody or a Occurrence.",
)
return
operations: list[adsk.cam.Operation] = []
for i, pocket in enumerate(pockets):
task.updateTaskProgress(i, len(pockets))
sketch = sketchPocketBoundary(component, pocket)
ops = millPocket(operation_type, pocket, setup, tool, sketch)
operations.extend(ops)
return operations
# Additive helpers
def getDefaultOrientationStudyParameters(
additiveMachineType: adsk.cam.AdditiveTechnologies,
) -> dict[str, Any]:
"""
Return the default parameters for a new additive orientation study in a
setup using a machine of the given additive type.
"""
# Determine if the default values of parameters should be SLA style as
# opposed to FFF style
useSLAStyleParams = additiveMachineType in [
adsk.cam.AdditiveTechnologies.SLATechnology,
adsk.cam.AdditiveTechnologies.BinderJettingTechnology,
adsk.cam.AdditiveTechnologies.MFJTechnology,
adsk.cam.AdditiveTechnologies.SLSTechnology,
]
criticalAngle = "35 deg" if useSLAStyleParams else "45 deg"
distanceToPlatform = "7 mm" if useSLAStyleParams else "0 mm"
frameWidth = "0 mm" if useSLAStyleParams else "5 mm"
ceilingClearance = "0 mm" if useSLAStyleParams else "5 mm"
rankingSupportVolume = "'2'" if useSLAStyleParams else "'10'"
rankingSupportArea = "'10'" if useSLAStyleParams else "'0'"
rankingBoundingBoxVolume = "'0'" if useSLAStyleParams else "'2'"
rankingPartHeight = "'0'" if useSLAStyleParams else "'6'"
rankingCOGHeight = "'0'" if useSLAStyleParams else "'6'"
orientation_params = {
"optimizeOrientationSmallestRotation": "180 deg",
"optimizeOrientationUsePreciseCalculation": "true",
"optimizeOrientationCriticalAngle": criticalAngle,
"optimizeOrientationDistanceToPlatform": distanceToPlatform,
"optimizeOrientationMoveToCenter": "true",
"optimizeOrientationFrameWidth": frameWidth,
"optimizeOrientationCeilingClearance": ceilingClearance,
"optimizeOrientationRankingSupportVolume": rankingSupportVolume,
"optimizeOrientationRankingSupportArea": rankingSupportArea,
"optimizeOrientationRankingBoundingBoxVolume": rankingBoundingBoxVolume,
"optimizeOrientationRankingPartHeight": rankingPartHeight,
"optimizeOrientationRankingCOGHeight": rankingCOGHeight,
}
return orientation_params
def createAutomaticOrientationStudy(
cam: adsk.cam.CAM,
setup: adsk.cam.Setup,
orientationName: str | None,
camParameters: dict[str, Any] | None,
task: CAMFunctionTaskContext,
) -> list[adsk.cam.Operation]:
"""
Create an automatic orientation study for each model occurrence in the
given setup.
If 'orientationName' is not an empty string, it will be used as the prefix
of the new orientation studies (as "<name>: <occurrence_name>"). Otherwise,
the prefix will be "Automatic Orientation: ".
"""
# Collect the created orientations for this setup
orientations: list[adsk.cam.Operation] = []
# Activate the setup to find the active manufacturing model
setup.activate()
activeMfgModel = None
for mfgModel in cam.manufacturingModels:
if mfgModel.isActive:
activeMfgModel = mfgModel
break
if activeMfgModel is None:
# Error if there is no active manufacturing model
CAMFunctionContext.fail(
message="Error creating automatic orientation study:",
error=f"No manufacturing model is active for '{setup.name}'.",
)
return orientations
task.updatePercentageProgress(10)
# Find all bodies and components for each model in the setup
setupBodies = []
setupComps = []
models = setup.models
for i in range(models.count):
model_item = models.item(i)
if model_item.objectType == adsk.fusion.Occurrence.classType():
setupComps.append(model_item.component)
validOccs = getValidOccurrences(model_item)
for occ in validOccs:
for k in range(occ.bRepBodies.count):
setupBodies.append(occ.bRepBodies.item(k))
else:
setupBodies.append(model_item)
setupComps.append(model_item.parentComponent)
task.updatePercentageProgress(30)
# Find all occurrences from the mfg model that reference a component or a body in the setup
occs = []
mfgModelOccs = getValidOccurrences(activeMfgModel.occurrence)
for occ in mfgModelOccs:
if occ.component in setupComps:
occs.append(occ)
continue
hasMatchedBody = any(
b in setupBodies
for b in [occ.bRepBodies.item(j) for j in range(occ.bRepBodies.count)]
)
if hasMatchedBody:
occs.append(occ)
if len(occs) == 0:
CAMFunctionContext.fail(
message="Error creating automatic orientation study:",
error=f"No component has been added to the scene in '{setup.name}'.",
)
return orientations
task.updatePercentageProgress(40)
# Get the machine from the setup
machine = setup.machine
if machine is None:
CAMFunctionContext.fail(
message="Error creating automatic orientation study:",
error=f"No machine found for '{setup.name}'.",
)
return orientations
additiveType = machine.capabilities.additiveTechnology
if additiveType == adsk.cam.AdditiveTechnologies.NATechnology:
CAMFunctionContext.fail(
message="Error creating automatic orientation study:",
error=f"Machine '{machine.name}' is not an additive machine for '{setup.name}'.",
)
return orientations
occsTask = task.makeSubTask(
progressRange=(40, 100),
numberOfTasks=len(occs),
)
with occsTask:
# Create the automatic orientation study for each occurrence in the
# active manufacturing model
for i, occ in enumerate(occs):
studyTask = occsTask.makeSubTaskByIndex(
index=i,
message=f"Creating orientation for '{occ.name}'",
numberOfTasks=2,
)
with studyTask:
operationInput = setup.operations.createInput("automatic_orientation")
operationInput.isAutoCalculating = False
orientationTarget = operationInput.parameters.itemByName(
"optimizeOrientationTarget"
)
orientationTarget.value.value = [occ]
# Use provided name for the new orientation, or default to
# "Automatic Orientation: <occurrence name>"
if isNonEmptyString(orientationName):
operationInput.displayName = orientationName
else:
operationInput.displayName = f"Automatic Orientation: {occ.name}"
# Get the default values for parameters
orientation_params = getDefaultOrientationStudyParameters(additiveType)
# Override default values with CAMParameters if provided
if camParameters:
orientation_params.update(camParameters)
# Set parameters for the orientation study
with studyTask.makeSubTaskByIndex(index=0) as paramTask:
warningParams = setCAMParameters(
inputParameters=orientation_params,
camParameters=operationInput.parameters,
task=paramTask,
)
async def worker() -> adsk.cam.Operation:
return setup.operations.add(operationInput)
with studyTask.makeSubTaskByIndex(index=1) as createTask:
orientation = createTask.runAsyncWorker(worker=worker)
orientations.append(orientation)
# Base success message, based on the name inputs
if isNonEmptyString(orientationName):
successMessage = f"Created an automatic orientation study named '{orientation.name}' for the body '{occ.name}' in '{setup.name}'"
else:
successMessage = f"Created an automatic orientation study named '{orientation.name}' in '{setup.name}'"
# Log warnings or success
if len(warningParams) > 0:
CAMFunctionContext.warn(
message=f"{successMessage} with warnings:",
warning=f"Cannot set parameter(s): {getListAsString(warningParams)}. It may imply that the parameter(s) do not exist for orientation studies.",
)
else:
# If the orientation name is different from the actual name
# of the created orientation, interrupt the execution
if isNonEmptyString(orientationName) and orientation.name != orientationName:
CAMFunctionContext.blockFutureExecution(
warning=f"{successMessage} (instead of {orientationName})."
)
else:
CAMFunctionContext.succeed(
message=f"{successMessage}."
)
return orientations