app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
Drew Owens
CAM: program number and output folder as asked, more strategies, honest toolpath status
4191b7d
6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
"""CAM: milling jobs from Fusion's Manufacture workspace, driven as data.
cam_tools lists Fusion's tool libraries and their tools. cam_job builds a setup (stock and work coordinate system) and its
operations from a spec, generates the toolpaths, reports every operation's toolpath status, warnings and errors and the
machining time, and posts G-code. cam_inspect reads a document's CAM back.
A document has no CAM product until the Manufacture workspace has been opened in it once. Products has no way to add one
(only item and itemByProductType, which raises or returns null), so the first job in a document opens Manufacture for the
moment it takes Fusion to create the product, puts the user's workspace back even when that fails, and says so in its
reply. Every later job, cam_inspect and cam_tools leave the workspace alone."""
import os
from pathlib import Path
import re
import threading
import time
import adsk.core
import adsk.fusion
try:
import adsk.cam # always present inside Fusion; offline tools load this package without it
except ImportError:
pass
from .core import active, require, resolve, target
# names here -> Fusion's strategy ids (setup.operations.compatibleStrategies)
STRATEGIES = {"face": "face", "adaptive2d": "adaptive2d", "pocket2d": "pocket2d", "contour2d": "contour2d", "drill": "drill",
"adaptive": "adaptive", "pocket_clearing": "pocket_clearing", "contour": "contour3d", "parallel": "parallel",
"scallop": "scallop"}
CONTOUR_2D = ("contour2d",)
POCKET_2D = ("adaptive2d", "pocket2d")
# Fusion's cycleType values, as its hole templates and posts name them (FANUC: G81, G83 with Q, G73 with Q)
DRILL_CYCLES = {"drill": "drilling", "peck": "deep-drilling", "chip_break": "chip-breaking"}
PECK_PARAMETERS = ("peckingDepth", "incrementalDepth")
LOOPS = {"outside": "OnlyOutsideLoops", "inside": "OnlyInsideLoops", "all": "AllLoops"}
LIBRARY_ALIASES = {"milling": "Milling Tools (Metric)", "hole": "Hole Making Tools (Metric)"}
HOLE_TOOLS = ("drill", "spot drill", "center drill", "reamer", "tap right hand", "tap left hand", "boring bar", "counter bore", "counter sink")
STOCK_MODES = {"relative_box": "RelativeBoxStock", "fixed_box": "FixedBoxStock", "solid": "SolidStock"}
STOCK_MODE_NAMES = {0: "fixed_box", 1: "relative_box", 2: "fixed_cylinder", 3: "relative_cylinder", 4: "fixed_tube",
5: "relative_tube", 6: "solid", 7: "previous_setup"}
# wcs.origin -> Fusion's wcs_origin_mode choice, and the parameter holding the box point for it
WCS_ORIGINS = {"stock_point": ("stockPoint", "wcs_origin_boxPoint"), "model_point": ("modelPoint", "wcs_origin_boxPoint"),
"model_origin": ("modelOrigin", None)}
OPERATION_STATES = {0: "valid", 1: "invalid", 2: "suppressed", 3: "no_toolpath"}
WCS_MATRIX_TO_MM = 1.0 # Setup.workCoordinateSystem comes in mm, unlike the API's usual cm
RAPID_MM_PER_MIN = 5000.0
TOOL_CHANGE_SECONDS = 15.0
FEED_SCALE_PERCENT = 100.0
DEFAULT_NC_FOLDER = Path(os.path.expanduser("~")) / "Documents" / "Fusion 360" / "NC Programs"
def mm(value):
"""A length expression Fusion reads in millimetres, whatever the document's units."""
return "%r mm" % float(value)
def text_lines(message):
return [line.strip() for line in str(message or "").splitlines() if line.strip()]
def set_parameter(parameters, name, expression, check=True):
"""Set a CAM parameter by expression and refuse a value Fusion flags (its error, not a silent default). An input's
parameters are not evaluated until Fusion builds the operation, so check=False there and parameter_problems after."""
parameter = parameters.itemByName(name)
require(parameter is not None, "unknown_parameter", "Fusion has no parameter '%s' here." % name)
parameter.expression = expression
if check:
problem = parameter_problems(parameters, [name])
require(not problem, "invalid_parameter_value", "Fusion refused %s = %s: %s" % (name, expression, "; ".join(problem)))
return parameter
def parameter_problems(parameters, names):
out = []
for name in names:
parameter = parameters.itemByName(name)
problem = str(getattr(parameter, "error", "") or "").strip() if parameter is not None else ""
if problem:
out.append("%s: %s" % (name, problem))
return out
def set_choice(parameters, name, value):
"""Set a choice parameter, checking the value against the choices Fusion offers for it."""
parameter = parameters.itemByName(name)
require(parameter is not None, "unknown_parameter", "Fusion has no parameter '%s' here." % name)
try:
choices = parameter.value.getChoices()
# Fusion lists the values quoted, as expressions: "'stockPoint'"
values = [str(v).strip("'") for v in choices[2]] if isinstance(choices, (list, tuple)) and len(choices) >= 3 else None
except Exception:
values = None
require(values is None or value in values, "unknown_choice", "%s takes %s, not '%s'." % (name, ", ".join(values or []), value))
return set_parameter(parameters, name, "'%s'" % value)
def machining_time(cam, what):
"""Fusion's estimate, asked in the units its reference gives (feed scale in %, rapid feed in cm/s, so 5000 mm/min
goes in as 8.33). Fusion 2705 ignores both and times rapids at its own rate; machining_data reports what it used."""
return cam.getMachiningTime(what, FEED_SCALE_PERCENT, RAPID_MM_PER_MIN / 600.0, TOOL_CHANGE_SECONDS)
def machining_data(timing):
out = {"seconds": round(timing.machiningTime, 1), "feedSeconds": round(timing.totalFeedTime, 1), "rapidSeconds": round(timing.totalRapidTime, 1),
"toolChangeSeconds": round(timing.totalToolChangeTime, 1), "feedDistanceMm": round(timing.feedDistance * 10, 1),
"rapidDistanceMm": round(timing.rapidDistance * 10, 1), "toolChanges": timing.toolChangeCount}
if timing.totalRapidTime > 0:
out["rapidMmPerMin"] = round(timing.rapidDistance * 10 / timing.totalRapidTime * 60)
out["basis"] = "Fusion's own estimate at the toolpaths' feeds; rapids at Fusion's own rate (rapidMmPerMin)"
return out
# ---- the CAM product -------------------------------------------------------------------------
def existing_cam(doc):
"""The document's CAM product, or None without touching the UI (Fusion raises or returns null when there is none)."""
if doc is None:
return None
try:
product = doc.products.itemByProductType("CAMProductType")
except RuntimeError:
return None
return adsk.cam.CAM.cast(product) if product is not None else None
def cam_product(app, doc):
"""(cam, workspace) for the document. workspace is None when the document already had CAM; otherwise it records the
round trip through Manufacture that creating the product takes, and whether the user's workspace came back."""
cam = existing_cam(doc)
if cam is not None:
return cam, None
ui = app.userInterface
before = ui.activeWorkspace.id if ui.activeWorkspace else None
manufacture = ui.workspaces.itemById("CAMEnvironment")
require(manufacture is not None, "cam_unavailable", "This Fusion has no Manufacture workspace.")
try:
opened = manufacture.activate()
cam = existing_cam(doc)
finally:
back = before or "FusionSolidEnvironment"
if ui.activeWorkspace is None or ui.activeWorkspace.id != back:
ui.workspaces.itemById(back).activate()
after = ui.activeWorkspace.id if ui.activeWorkspace else None
require(cam is not None, "cam_unavailable", "Fusion did not create the document's CAM product (Manufacture %s)." % ("opened" if opened else "refused to open"))
return cam, {"switched": True, "from": before, "to": "CAMEnvironment", "restored": after == (before or "FusionSolidEnvironment"), "now": after}
# ---- tools ---------------------------------------------------------------------------------
def tool_libraries():
"""Every library a job can take tools from, by name: Fusion's samples and the user's local libraries."""
libraries = adsk.cam.CAMManager.get().libraryManager.toolLibraries
found = {}
for location, kind in ((adsk.cam.LibraryLocations.Fusion360LibraryLocation, "sample"),
(adsk.cam.LibraryLocations.LocalLibraryLocation, "local")):
try:
urls = libraries.childAssetURLs(libraries.urlByLocation(location))
except Exception:
continue
for url in urls:
name = url.toString().rstrip("/").split("/")[-1]
if name.lower().endswith(".json"):
name = name[:-5]
found.setdefault(name, (url, kind))
return found
def library_tools(name, cam=None, kind=None, cache=None):
"""(library name, tools) for a library named by alias (milling, hole), by name, or 'document' for the tools the
document's CAM already uses; with kind, only the tools of that type, found by Fusion's own query (reading every
tool from Python took 2.1 s on the 258-tool hole library, the query 1.3 s). Loading a library costs 1 to 3 s, so a
job loads each once (cache)."""
if name == "document":
require(cam is not None, "tool_library_missing", "This document has no CAM yet, so it has no tools of its own.")
label, library = "document", cam.documentToolLibrary
elif cache is not None and ("library", name) in cache:
label, library = cache[("library", name)] # a job's tool lists are keyed ("tools", library, type)
else:
label = LIBRARY_ALIASES.get(name, name)
found = tool_libraries()
match = found.get(label) or next((v for k, v in found.items() if k.lower() == label.lower()), None)
require(match is not None, "tool_library_missing", "No tool library named '%s'; cam_tools lists them: %s." % (name, ", ".join(sorted(found))[:600]))
library = adsk.cam.CAMManager.get().libraryManager.toolLibraries.toolLibraryAtURL(match[0])
if cache is not None:
cache[("library", name)] = (label, library)
if kind:
try:
query = library.createQuery()
query.criteria.add("tool_type", adsk.core.ValueInput.createByString(kind))
return label, [result.tool for result in query.execute()]
except Exception:
pass # read every tool instead
return label, [library.item(i) for i in range(library.count)]
def tool_data(tool):
def value(name):
p = tool.parameters.itemByName(name)
return p.value.value if p is not None else None
def length(name):
v = value(name)
return round(v * 10, 3) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
return {"type": value("tool_type"), "diameterMm": length("tool_diameter"), "description": value("tool_description") or "",
"flutes": value("tool_numberOfFlutes"), "cornerRadiusMm": length("tool_cornerRadius"), "fluteLengthMm": length("tool_fluteLength"),
"number": value("tool_number"), "productId": value("tool_productId") or "", "vendor": value("tool_vendor") or ""}
def pick_tool(spec, strategy, cam=None, cache=None):
"""(tool, data) from the named library: the requested type (and description, flutes), nearest the requested diameter."""
kind = spec.get("type") or ("drill" if strategy == "drill" else "flat end mill")
library = spec.get("library") or ("hole" if kind in HOLE_TOOLS else "milling")
cache = {} if cache is None else cache
if ("tools", library, kind) not in cache:
name, tools = library_tools(library, cam, kind, cache)
cache[("tools", library, kind)] = (name, [(t, tool_data(t)) for t in tools])
name, rows = cache[("tools", library, kind)]
rows = [(t, d) for t, d in rows if d["type"] == kind]
if spec.get("description"):
needle = spec["description"].lower()
rows = [(t, d) for t, d in rows if needle in d["description"].lower()]
if spec.get("flutes"):
rows = [(t, d) for t, d in rows if d["flutes"] == spec["flutes"]]
require(rows, "tool_not_found", "No '%s'%s in %s; list them with cam_tools." % (
kind, (" like '%s'" % spec["description"]) if spec.get("description") else "", name))
want = spec.get("diameterMm")
if want is None:
tool, data = rows[0]
else:
# nearest diameter; a tie goes to the smaller tool, which fits wherever the bigger one would
tool, data = min(rows, key=lambda r: (abs((r[1]["diameterMm"] or 0) - want), r[1]["diameterMm"] or 0))
return tool, dict(data, library=name)
# ---- geometry ------------------------------------------------------------------------------
def hole_faces(bodies, diameter_mm, tolerance_mm=0.1):
"""Concave cylindrical faces of the setup's bodies with the drill's diameter: the holes to drill."""
out = []
for body in bodies:
for face in body.faces:
geometry = face.geometry
if geometry.objectType == adsk.core.Cylinder.classType() and abs(geometry.radius * 20 - diameter_mm) <= tolerance_mm:
normal = face.evaluator.getNormalAtPoint(face.pointOnFace)[1]
to_axis = geometry.origin.vectorTo(face.pointOnFace)
if normal.dotProduct(to_axis) < 0: # pointing at the axis: a hole, not a boss
out.append(face)
return out
def set_peck(q, depth_mm):
"""The peck depth goes in whichever of Fusion's peck parameters the chosen cycle enables (Fusion 2705's drill has
both peckingDepth and the older incrementalDepth)."""
names = [n for n in PECK_PARAMETERS if q.itemByName(n) is not None]
require(names, "unknown_parameter", "Fusion's drill has none of " + ", ".join(PECK_PARAMETERS) + ".")
enabled = [n for n in names if getattr(q.itemByName(n), "isEnabled", True)]
for name in enabled or names[:1]:
set_parameter(q, name, mm(depth_mm))
return enabled or names[:1]
def selection_problems(selections):
out = []
for i in range(selections.count):
s = selections.item(i)
for flag, attr in (("hasError", "error"), ("hasWarning", "warning")):
try:
if getattr(s, flag):
out.extend(text_lines(getattr(s, attr)))
except Exception:
pass
return out
def pocket_floors(bodies):
"""Upward planar faces below the top of each setup body: the floors a 2D pocket clears when no faces are given.
(Fusion's pocket recognition selected the pocket on a test plate but generated an empty toolpath.)"""
out = []
for body in bodies:
top = body.boundingBox.maxPoint.z
for face in body.faces:
if face.geometry.objectType != adsk.core.Plane.classType():
continue
normal = face.evaluator.getNormalAtPoint(face.pointOnFace)[1]
if normal.z > 0.999 and face.pointOnFace.z < top - 1e-4:
out.append(face)
return out
def apply_geometry(op, strategy, spec, design, value, bodies):
"""Select a 2D operation's geometry: the faces given, else the outside of the setup bodies' silhouette (2D contour)
or the bodies' pocket floors (2D pocket and adaptive). Returns what was chosen, so the reply says where the toolpath
came from."""
faces = [resolve(design, value, ref, ("BRepFace",)) for ref in spec.get("faces") or []]
loops = spec.get("loops", "outside")
if strategy in CONTOUR_2D:
parameter = op.parameters.itemByName("contours")
require(parameter is not None, "unknown_parameter", "Fusion's 2D contour has no 'contours' parameter.")
selections = parameter.value.getCurveSelections()
if faces:
for face in faces:
s = selections.createNewFaceContourSelection()
s.inputGeometry = [face]
s.loopType = getattr(adsk.cam.LoopTypes, LOOPS[loops])
chosen = {"source": "faces", "faces": len(faces), "loops": loops}
else:
s = selections.createNewSilhouetteSelection()
s.inputGeometry = list(bodies)
s.loopType = getattr(adsk.cam.LoopTypes, LOOPS[loops])
chosen = {"source": "silhouette of the setup bodies", "loops": loops}
else:
require("loops" not in spec, "invalid_input", "loops applies to contour2d.")
parameter = op.parameters.itemByName("pockets")
require(parameter is not None, "unknown_parameter", "Fusion's %s has no 'pockets' parameter." % strategy)
selections = parameter.value.getCurveSelections()
source = "faces"
if not faces:
faces, source = pocket_floors(bodies), "pocket floors of the setup bodies (upward faces below the top)"
require(faces, "no_pockets", "The setup's bodies have no pocket floors; pass faces for %s." % strategy)
for face in faces:
s = selections.createNewPocketSelection()
s.inputGeometry = [face]
chosen = {"source": source, "faces": len(faces)}
parameter.value.applyCurveSelections(selections)
problems = selection_problems(parameter.value.getCurveSelections())
if problems:
chosen["problems"] = problems
return chosen
# ---- setup: stock and WCS -----------------------------------------------------------------
def check_setup_spec(spec):
"""Refuse a stock or WCS spec that does not fit together, before anything in the document changes."""
stock = spec.get("stock") or {}
mode = stock.get("mode")
require(mode == "solid" or not stock.get("bodies"), "invalid_input", "stock.bodies applies to solid stock.")
require(mode != "solid" or stock.get("bodies"), "invalid_input", "Solid stock needs stock.bodies.")
require(mode == "fixed_box" or "sizeMm" not in stock, "invalid_input", "stock.sizeMm applies to fixed_box stock.")
require(mode != "fixed_box" or (isinstance(stock.get("sizeMm"), list) and len(stock["sizeMm"]) == 3), "invalid_input",
"Fixed box stock needs stock.sizeMm [x, y, z].")
require(not stock.get("offsetMm") or mode in (None, "relative_box"), "invalid_input", "Stock offsets apply to relative_box stock.")
wcs = spec.get("wcs") or {}
require(wcs.get("origin") or not wcs.get("point"), "invalid_input", "wcs.point needs wcs.origin stock_point or model_point.")
require(wcs.get("origin") != "model_origin" or not wcs.get("point"), "invalid_input", "model_origin takes no wcs.point.")
def stock_input(sinput, spec, stock_bodies):
"""The stock mode goes on the setup input through Fusion's typed API; solid stock takes its bodies."""
mode = (spec.get("stock") or {}).get("mode")
if mode == "solid":
sinput.stockSolids = stock_bodies
elif mode:
sinput.stockMode = getattr(adsk.cam.SetupStockModes, STOCK_MODES[mode])
def configure_setup(q, spec):
"""Stock offsets or size and the WCS origin, on the new setup's parameters (on the setup input Fusion has not
evaluated them yet: they read back as unspecified and not editable)."""
stock = spec.get("stock") or {}
offsets = stock.get("offsetMm")
if offsets:
set_choice(q, "job_stockOffsetMode", "simple")
for side, name in (("sides", "job_stockOffsetSides"), ("top", "job_stockOffsetTop"), ("bottom", "job_stockOffsetBottom")):
if side in offsets:
set_parameter(q, name, mm(offsets[side]))
if stock.get("mode") == "fixed_box":
for axis, length in zip("XYZ", stock["sizeMm"]):
set_parameter(q, "job_stockFixed" + axis, mm(length))
wcs = spec.get("wcs") or {}
if wcs.get("origin"):
choice, point_parameter = WCS_ORIGINS[wcs["origin"]]
set_choice(q, "wcs_origin_mode", choice)
if point_parameter:
set_choice(q, point_parameter, wcs.get("point", "top center"))
def setup_data(setup):
"""The stock and the WCS Fusion built, in mm and model coordinates. Fusion keeps the stock box (stockXLow ... in cm)
relative to the WCS, and Setup.workCoordinateSystem in mm (seen on Fusion 2705 in mm and inch documents alike)."""
q = setup.parameters
def cm(name):
p = q.itemByName(name)
v = p.value.value if p is not None else None
return v * 10 if isinstance(v, (int, float)) and not isinstance(v, bool) else None
out = {"name": setup.name}
try:
out["stockMode"] = STOCK_MODE_NAMES.get(int(setup.stockMode), str(setup.stockMode))
except Exception:
pass
low = [cm("stock%sLow" % a) for a in "XYZ"]
high = [cm("stock%sHigh" % a) for a in "XYZ"]
if None in low or None in high:
return out
out["stockSizeMm"] = [round(h - l, 3) for l, h in zip(low, high)]
try:
cell = setup.workCoordinateSystem.getCell
origin = [cell(r, 3) * WCS_MATRIX_TO_MM for r in range(3)]
axes = [[cell(r, c) for r in range(3)] for c in range(3)] # the WCS x, y and z axes in model space
except Exception:
out["stockBoxWcsMm"] = {a: [round(l, 3), round(h, 3)] for a, l, h in zip("xyz", low, high)}
return out
corners = [[(high if (i >> k) & 1 else low)[k] for k in range(3)] for i in range(8)]
model = [[origin[r] + sum(axes[k][r] * c[k] for k in range(3)) for r in range(3)] for c in corners]
out["stockBoxMm"] = {a: [round(min(p[r] for p in model), 3), round(max(p[r] for p in model), 3)] for r, a in enumerate("xyz")}
out["wcs"] = {"originMm": [round(v, 3) for v in origin], "xAxis": [round(v, 6) for v in axes[0]], "zAxis": [round(v, 6) for v in axes[2]]}
return out
# ---- operations --------------------------------------------------------------------------
def op_status(op, cam=None):
"""An operation's toolpath as it stands: status is generated (a valid toolpath), empty (generated to nothing), error,
outdated (a toolpath the model has since invalidated), suppressed, or not_generated."""
warnings = text_lines(op.warning) if op.hasWarning else []
errors = text_lines(op.error) if op.hasError else []
has, valid = bool(op.hasToolpath), bool(op.isToolpathValid)
suppressed = bool(getattr(op, "isSuppressed", False))
if errors:
status = "error"
elif suppressed:
status = "suppressed"
elif has and valid:
status = "generated"
elif has:
status = "outdated"
elif any("empty" in w.lower() for w in warnings):
status = "empty"
else:
status = "not_generated"
out = {"name": op.name, "strategy": op.strategy, "status": status, "hasToolpath": has, "valid": valid,
"warnings": warnings, "errors": errors}
try:
out["state"] = OPERATION_STATES.get(int(op.operationState), str(op.operationState))
except Exception:
pass
if cam is not None and status == "generated":
try:
out["machiningSeconds"] = round(machining_time(cam, op).machiningTime, 1)
except Exception:
pass
return out
def cam_tools(app, args):
library = args.get("library", "milling")
name, tools = library_tools(library, existing_cam(app.activeDocument) if library == "document" else None, args.get("type"))
rows = [tool_data(t) for t in tools]
if args.get("type"):
rows = [t for t in rows if t["type"] == args["type"]]
if args.get("description"):
rows = [t for t in rows if args["description"].lower() in t["description"].lower()]
if args.get("minDiameterMm") is not None:
rows = [t for t in rows if (t["diameterMm"] or 0) >= args["minDiameterMm"]]
if args.get("maxDiameterMm") is not None:
rows = [t for t in rows if (t["diameterMm"] or 0) <= args["maxDiameterMm"]]
out = {"library": name, "count": len(rows), "tools": rows[: int(args.get("limit", 100))],
"libraries": [{"name": k, "location": v[1]} for k, v in sorted(tool_libraries().items())]}
if not args.get("type"):
out["types"] = sorted({t["type"] for t in rows if t["type"]})
return out
def cam_job(app, args):
require(threading.current_thread() is threading.main_thread(), "wrong_thread", "CAM requires the main thread.")
doc, design, value = target(app, args, True, model_write=False)
bodies = [resolve(design, value, ref, ("BRepBody",)) for ref in args["bodies"]]
require(bodies, "invalid_selection", "A job needs at least one body.")
check_setup_spec(args)
stock_bodies = [resolve(design, value, ref, ("BRepBody",)) for ref in (args.get("stock") or {}).get("bodies") or []]
for spec in args["operations"]:
strategy = spec["strategy"]
require(strategy in STRATEGIES, "unsupported_strategy", "Strategies: " + ", ".join(STRATEGIES))
require(strategy in CONTOUR_2D + POCKET_2D or not spec.get("faces"), "invalid_input", "faces apply to contour2d, pocket2d and adaptive2d.")
require(strategy in CONTOUR_2D or "loops" not in spec, "invalid_input", "loops applies to contour2d.")
require(strategy == "drill" or not ({"cycle", "peckDepthMm", "holeFaces"} & set(spec)), "invalid_input", "cycle, peckDepthMm and holeFaces apply to drill.")
require(spec.get("cycle", "drill") != "drill" or spec.get("peckDepthMm") is None, "invalid_input", "peckDepthMm needs cycle peck or chip_break.")
cam, workspace = cam_product(app, doc)
tools_before = cam.documentToolLibrary.count
sinput = cam.setups.createInput(adsk.cam.OperationTypes.MillingOperation)
sinput.models = bodies
stock_input(sinput, args, stock_bodies)
setup = cam.setups.add(sinput)
try:
if args.get("name"):
setup.name = args["name"]
configure_setup(setup.parameters, args)
result = build_job(app, cam, setup, design, value, bodies, args)
except Exception:
# never leave a half-made setup behind: a failed job changes nothing
try:
setup.deleteMe()
except Exception:
pass
forget_tools(cam, tools_before)
raise
if workspace:
result["workspace"] = workspace
return result
def generate(cam, what, deadline):
"""Generate the toolpaths of a setup or one operation and wait; returns how many operations Fusion said it would do."""
future = cam.generateToolpath(what)
while not future.isGenerationCompleted:
require(time.perf_counter() < deadline, "generate_timeout", "Toolpaths did not finish in time; simplify the job or raise timeoutSeconds.")
adsk.doEvents()
time.sleep(0.25)
try:
return future.numberOfOperations
except Exception:
return None
def forget_tools(cam, count):
"""The tools a failed job brought into the document's tool library go too (Fusion appends them as operations use
them, and deleting the setup leaves them behind)."""
try:
library = cam.documentToolLibrary
for index in range(library.count - 1, count - 1, -1):
library.remove(index)
except Exception:
pass
def build_job(app, cam, setup, design, value, bodies, args):
created, notes, cache = [], [], {}
for spec in args["operations"]:
strategy = spec["strategy"]
tool, tool_info = pick_tool(spec.get("tool") or {}, strategy, cam, cache)
oinput = setup.operations.createInput(STRATEGIES[strategy])
oinput.tool = tool
overrides = spec.get("parameters") or {}
for name, expression in overrides.items():
set_parameter(oinput.parameters, name, str(expression), check=False)
op = setup.operations.add(oinput)
problems = parameter_problems(op.parameters, overrides)
require(not problems, "invalid_parameter_value", "Fusion refused the parameters of %s: %s" % (op.name, "; ".join(problems)))
if spec.get("name"):
try:
op.name = spec["name"]
except Exception as error:
# names are unique in the document; keep Fusion's and say so rather than fail the job
notes.append("%s: Fusion kept its own name, not '%s' (%s)" % (op.name, spec["name"], str(error).strip()[:120]))
row = {"name": op.name, "strategy": strategy, "tool": tool_info}
if strategy == "drill":
dia = tool_info["diameterMm"]
faces = [resolve(design, value, ref, ("BRepFace",)) for ref in spec["holeFaces"]] if spec.get("holeFaces") else hole_faces(bodies, dia)
require(faces, "no_holes", "No %.2f mm holes on the setup's bodies; pass holeFaces or another drill diameter." % dia)
op.parameters.itemByName("holeFaces").value.value = faces
cycle = spec.get("cycle", "drill")
set_choice(op.parameters, "cycleType", DRILL_CYCLES[cycle])
row["geometry"] = {"source": "holeFaces" if spec.get("holeFaces") else "holes of the drill's diameter", "holes": len(faces)}
row["cycle"] = cycle
if spec.get("peckDepthMm") is not None:
row["peck"] = {"depthMm": spec["peckDepthMm"], "parameters": set_peck(op.parameters, spec["peckDepthMm"])}
notes.append("%s: %d holes of %.2f mm, %s cycle" % (op.name, len(faces), dia, cycle))
elif strategy in CONTOUR_2D or strategy in POCKET_2D:
row["geometry"] = apply_geometry(op, strategy, spec, design, value, bodies)
created.append(row)
started = time.perf_counter()
deadline = started + float(args.get("timeoutSeconds", 900))
covered = generate(cam, setup, deadline)
ops = [setup.operations.item(i) for i in range(setup.operations.count)]
# an operation Fusion left untouched (no toolpath, no warning, no error) gets one more try on its own
retried = [op for op in ops if op_status(op)["status"] == "not_generated"]
for op in retried:
generate(cam, op, deadline)
generated = round(time.perf_counter() - started, 1)
toolpaths = [op_status(op, cam) for op in ops]
for row, path in zip(created, toolpaths):
row.update({k: path[k] for k in ("status", "warnings", "errors") if k in path})
if "machiningSeconds" in path:
row["machiningSeconds"] = path["machiningSeconds"]
timing = machining_time(cam, setup)
done = sum(1 for p in toolpaths if p["status"] == "generated")
result = {"target": value, "setup": setup_data(setup), "operations": created, "toolpaths": toolpaths,
"generated": {"operations": len(toolpaths), "withToolpath": done, "complete": done == len(toolpaths), "coveredByFusion": covered,
"retried": [op.name for op in retried]},
"generateSeconds": generated, "notes": notes,
"machining": machining_data(timing)}
post = args.get("post")
if post:
missing = [p["name"] for p in toolpaths if p["status"] != "generated"]
if missing:
# Fusion's post leaves an operation without a valid toolpath out of the program without a word
result["post"] = {"posted": False, "reason": "operations without a valid toolpath: " + ", ".join(missing),
"hint": "Fix or remove them and run the job again; Fusion's post would leave them out of the program silently."}
else:
result["post"] = post_program(cam, setup, post)
return result
# ---- posting -------------------------------------------------------------------------------
def find_post(name):
library = adsk.cam.CAMManager.get().libraryManager.postLibrary
configs = list(library.createQuery(adsk.cam.LibraryLocations.Fusion360LibraryLocation).execute())
config = next((c for c in configs if c.description.lower() == name.lower()), None) or \
next((c for c in configs if name.lower() in c.description.lower()), None)
require(config is not None, "post_not_found", "No post named like '%s' in Fusion's library (try FANUC, Grbl, HAAS - Next Generation Control)." % name)
return config
def set_string(parameters, name, text):
parameter = parameters.itemByName(name)
require(parameter is not None, "unknown_parameter", "Fusion has no parameter '%s' here." % name)
parameter.value.value = text
def read_value(parameters, name):
parameter = parameters.itemByName(name)
return parameter.value.value if parameter is not None else None
def post_program(cam, setup, spec):
"""Post the setup through an NC program with a post from Fusion's library, matched by name ("FANUC", "Grbl",
"HAAS - Next Generation Control"), into spec.folder under the program name or number.
The program name and comment: when an NC program's operations come from one setup, Fusion copies the setup's
job_programName and job_programComment into nc_program_name and nc_program_comment (NCProgramInput.operations), so a
name set before the operations was overwritten with the setup's default, 1001. They are set on the setup, and on the
NC program after its operations and again after its post. The folder goes in with forward slashes, as Fusion's
samples write it."""
folder = Path(spec["folder"])
require(folder.is_absolute(), "invalid_destination", "post.folder must be an absolute path.")
config = find_post(spec.get("machine", "FANUC"))
program = str(spec.get("program", "1001"))
numeric = any(word in config.description.lower() for word in ("fanuc", "haas"))
require(not numeric or (program.isdigit() and 0 < int(program) <= 9999), "invalid_program",
"The %s post takes a program number from 1 to 9999, not '%s'." % (config.description, program))
comment = spec.get("comment")
folder.mkdir(parents=True, exist_ok=True)
set_string(setup.parameters, "job_programName", program)
if comment is not None:
set_string(setup.parameters, "job_programComment", comment)
def apply(q):
set_string(q, "nc_program_filename", program)
set_string(q, "nc_program_name", program)
if comment is not None:
set_string(q, "nc_program_comment", comment)
set_string(q, "nc_program_output_folder", folder.as_posix())
q.itemByName("nc_program_openInEditor").value.value = False
ninput = cam.ncPrograms.createInput()
ninput.displayName = setup.name + " NC"
ninput.operations = [setup]
apply(ninput.parameters)
nc = cam.ncPrograms.add(ninput)
try:
return post_through(nc, config, apply, folder, program)
except Exception:
try:
nc.deleteMe() # a failed post leaves no NC program behind either
except Exception:
pass
raise
def post_through(nc, config, apply, folder, program):
nc.postConfiguration = config
apply(nc.parameters)
kept = {"program": read_value(nc.parameters, "nc_program_name"), "folder": read_value(nc.parameters, "nc_program_output_folder")}
# the program asked for replaces a file of the same name: remove it first, so the file found afterwards is this post's
existing = folder / ("%s.%s" % (program, getattr(config, "extension", "") or "nc"))
replaced = existing.exists()
if replaced:
existing.unlink()
started = time.time()
require(nc.postProcess(adsk.cam.NCProgramPostProcessOptions.create()), "post_failed", "Fusion's post processor reported a failure.")
found = newest(folder, program, started)
moved = False
if found is None:
found = newest(DEFAULT_NC_FOLDER, program, started)
require(found is not None, "post_output_missing", "The post ran but its file is neither in post.folder nor in Fusion's NC folder.")
destination = folder / found.name
destination.write_bytes(found.read_bytes())
found.unlink()
found, moved = destination, True
text = found.read_text(errors="replace")
head = text.splitlines()[:6]
numbers = [m.group(1) for m in (re.match(r"O(\d+)", line) for line in head) if m]
out = {"posted": True, "machine": config.description, "ncProgram": nc.name, "file": str(found), "bytes": found.stat().st_size,
"lines": text.count("\n"), "head": head, "program": program, "ncProgramKept": kept,
"programInHeader": (int(numbers[0]) == int(program)) if numbers and program.isdigit() else any(program in line for line in head[:4])}
try:
out["operations"] = [o.name for o in nc.filteredOperations]
except Exception:
pass
if moved:
out["movedFromFusionFolder"] = True
if replaced:
out["replacedExisting"] = True
return out
def newest(folder, program, since):
if not folder.exists():
return None
files = [p for p in folder.glob(program + ".*") if p.stat().st_mtime >= since - 5]
return max(files, key=lambda p: p.stat().st_mtime) if files else None
# ---- inspection ----------------------------------------------------------------------------
def cam_inspect(app, args):
doc, design, value = active(app)
cam = existing_cam(doc)
if cam is None:
return {"target": value, "setups": [], "ncPrograms": [], "note": "This document has no CAM yet; cam_job creates it."}
setups = []
for i in range(cam.setups.count):
setup = cam.setups.item(i)
row = setup_data(setup)
row["operations"] = []
for j in range(setup.operations.count):
op = setup.operations.item(j)
status = op_status(op, cam)
try:
status["tool"] = tool_data(op.tool) if op.tool is not None else None
except Exception:
pass
row["operations"].append(status)
setups.append(row)
programs = []
for i in range(cam.ncPrograms.count):
nc = cam.ncPrograms.item(i)
try:
post = nc.postConfiguration.description if nc.postConfiguration else None
except Exception:
post = None
programs.append({"name": nc.name, "post": post, "program": read_value(nc.parameters, "nc_program_name"),
"filename": read_value(nc.parameters, "nc_program_filename"), "folder": read_value(nc.parameters, "nc_program_output_folder")})
return {"target": value, "setups": setups, "ncPrograms": programs}
OPERATIONS = {"cam_tools": cam_tools, "cam_job": cam_job, "cam_inspect": cam_inspect}
WRITES = {"cam_job"}