← Commit history

Merge PR #435: CAM: program number and output folder as asked, more strategies, honest toolpath status (feature/cam-coverage -> master) merge

Colby Knox ·b18e7a3e7a ·6d ago ·parents 1881d91, 4191b7d

Showing changes introduced by the merge (against its first parent).

6 files changed +1346−154 diff truncated
addin/AdomBridge/commands/mechanical/cam.py+620−105
@@ -1,10 +1,16 @@-"""CAM: milling jobs from Fusion's Manufacture workspace, driven as data. cam_tools searches Fusion's tool-libraries; cam_job builds a setup and its operations from a spec, generates the toolpaths, posts G-code-and reports machining time and every operation's warnings; cam_inspect reads the document's CAM.-Strategies here need no hand-picked geometry (face, adaptive, 3D contour, parallel, scallop, pocket-clearing) except drilling, whose holes are chosen by diameter on the setup's bodies when not given."""+"""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 @@ -17,59 +23,216 @@ except ImportError:  from .core import active, require, resolve, target -STRATEGIES = ("face", "adaptive", "contour", "parallel", "scallop", "pocket_clearing", "drill")-# Fusion's own strategy ids where they differ from the names here (setup.operations.compatibleStrategies)-FUSION_STRATEGY = {"contour": "contour3d"}-LIBRARIES = {"milling": "Milling Tools (Metric)", "hole": "Hole Making Tools (Metric)"}+# 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 cam_product(app, doc):-    """The document's CAM product. A document has none until the Manufacture workspace has been opened-    once (Fusion raises), so open it for a moment and come back to the workspace the user was in."""+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:-        return adsk.cam.CAM.cast(doc.products.itemByProductType("CAMProductType"))+        product = doc.products.itemByProductType("CAMProductType")     except RuntimeError:-        pass+        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-    previous = ui.activeWorkspace.id if ui.activeWorkspace else None-    ui.workspaces.itemById("CAMEnvironment").activate()-    cam = adsk.cam.CAM.cast(doc.products.itemByProductType("CAMProductType"))-    if previous and previous != "CAMEnvironment":-        ui.workspaces.itemById(previous).activate()-    return cam+    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}  -def library_tools(kind):+# ---- 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-    root = libraries.urlByLocation(adsk.cam.LibraryLocations.Fusion360LibraryLocation)-    url = next((u for u in libraries.childAssetURLs(root) if u.toString().endswith(LIBRARIES[kind])), None)-    require(url is not None, "tool_library_missing", "Fusion's sample library '%s' is not installed." % LIBRARIES[kind])-    library = libraries.toolLibraryAtURL(url)-    return [library.item(i) for i in range(library.count)]+    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-    diameter = value("tool_diameter")-    return {"type": value("tool_type"), "diameterMm": round(diameter * 10, 3) if isinstance(diameter, (int, float)) else None,-            "description": value("tool_description") or "", "flutes": value("tool_numberOfFlutes")}++    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):-    """The library tool of the requested type nearest the requested diameter."""+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")-    tools = [t for t in library_tools("hole" if kind in ("drill", "spot drill", "center drill") else "milling") if tool_data(t)["type"] == kind]-    require(tools, "tool_not_found", "No '%s' in Fusion's sample libraries; list them with cam_tools." % kind)+    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:-        return tools[0]-    # nearest diameter; a tie goes to the smaller tool, which fits wherever the bigger one would-    return min(tools, key=lambda t: (abs((tool_data(t)["diameterMm"] or 0) - want), tool_data(t)["diameterMm"] or 0))+        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."""@@ -85,18 +248,218 @@ def hole_faces(bodies, diameter_mm, tolerance_mm=0.1):     return out  -def op_status(op):-    return {"name": op.name, "strategy": op.strategy, "hasToolpath": op.hasToolpath, "valid": op.isToolpathValid,-            "warning": (str(op.warning).strip() if op.hasWarning else None), "error": (str(op.error).strip() if op.hasError else None)}+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):-    kind = args.get("library", "milling")-    tools = [tool_data(t) for t in library_tools(kind)]+    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"):-        tools = [t for t in tools if t["type"] == args["type"]]-    return {"library": LIBRARIES[kind], "count": len(tools), "tools": tools[: int(args.get("limit", 100))],-            "types": sorted({t["type"] for t in tools if t["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):@@ -104,120 +467,272 @@ def cam_job(app, args):     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.")-    cam = cam_product(app, doc)+    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)-    if args.get("name"):-        setup.name = args["name"]     try:-        return build_job(app, cam, setup, design, value, bodies, args)+        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 = [], []+    created, notes, cache = [], [], {}     for spec in args["operations"]:         strategy = spec["strategy"]-        require(strategy in STRATEGIES, "unsupported_strategy", "Strategies: " + ", ".join(STRATEGIES))-        tool = pick_tool(spec.get("tool") or {}, strategy)-        oinput = setup.operations.createInput(FUSION_STRATEGY.get(strategy, strategy))+        tool, tool_info = pick_tool(spec.get("tool") or {}, strategy, cam, cache)+        oinput = setup.operations.createInput(STRATEGIES[strategy])         oinput.tool = tool-        for name, expression in (spec.get("parameters") or {}).items():-            parameter = oinput.parameters.itemByName(name)-            require(parameter is not None, "unknown_parameter", "Operation '%s' has no parameter '%s'." % (strategy, name))-            parameter.expression = str(expression)+        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"):-            op.name = spec["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_data(tool)["diameterMm"]+            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-            notes.append("%s: %d holes of %.2f mm" % (op.name, len(faces), dia))-        created.append({"name": op.name, "strategy": strategy, "tool": tool_data(tool)})+            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()-    future = cam.generateToolpath(setup)     deadline = started + float(args.get("timeoutSeconds", 900))-    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)-    generated = round(time.perf_counter() - started, 1)+    covered = generate(cam, setup, deadline)     ops = [setup.operations.item(i) for i in range(setup.operations.count)]-    timing = cam.getMachiningTime(setup, 1.0, RAPID_MM_PER_MIN / 10, TOOL_CHANGE_SECONDS)-    result = {"target": value, "setup": setup.name, "operations": created, "toolpaths": [op_status(o) for o in ops],+    # 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": {"seconds": round(timing.machiningTime, 1), "feedDistanceMm": round(timing.feedDistance * 10, 1),-                            "rapidDistanceMm": round(timing.rapidDistance * 10, 1), "toolChanges": timing.toolChangeCount,-                            "assumptions": "feed scale 1.0, rapid %d mm/min, %d s per tool change" % (RAPID_MM_PER_MIN, TOOL_CHANGE_SECONDS)}}+              "machining": machining_data(timing)}     post = args.get("post")     if post:-        result["post"] = post_program(cam, setup, post, started_at=time.time())+        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  -def post_program(cam, setup, spec, started_at):-    """Post the setup's toolpaths through an NC program with a post from Fusion's library, matched by-    name ("FANUC", "HAAS - Next Generation Control", "Grbl"). Fusion writes to its default NC folder-    whatever folder is asked for, so the file is collected from there into the requested one."""-    folder = Path(spec["folder"])-    require(folder.is_absolute(), "invalid_destination", "post.folder must be an absolute path.")-    folder.mkdir(parents=True, exist_ok=True)-    name = spec.get("machine", "FANUC")+# ---- 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.parameters.itemByName("nc_program_filename").value.value = program-    # the post takes the program number (O2001 on FANUC) from the program's name, not its file name-    ninput.parameters.itemByName("nc_program_name").value.value = program-    ninput.parameters.itemByName("nc_program_openInEditor").value.value = False     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.")-    default = nc.parameters.itemByName("nc_program_default_output_folder")-    candidates = [Path(str(default.value.value))] if default is not None and default.value.value else []-    candidates.append(Path(os.path.expanduser("~")) / "Documents" / "Fusion 360" / "NC Programs")-    found = None-    for place in candidates:-        for path in place.glob(program + ".*") if place.exists() else []:-            if path.stat().st_mtime >= started_at - 5:-                found = path-    require(found is not None, "post_output_missing", "The post ran but its file was not found in Fusion's NC folder.")-    destination = folder / found.name-    if destination.resolve() != found.resolve():+    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()-    text = destination.read_text(errors="replace")-    return {"machine": config.description, "file": str(destination), "bytes": destination.stat().st_size,-            "lines": text.count("\n"), "head": text.splitlines()[:6]}+        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)-    try:-        cam = adsk.cam.CAM.cast(doc.products.itemByProductType("CAMProductType"))-    except RuntimeError:-        return {"setups": [], "note": "This document has no CAM yet; cam_job creates it."}+    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)-        ops = [setup.operations.item(j) for j in range(setup.operations.count)]-        setups.append({"name": setup.name, "operations": [op_status(o) for o in ops]})-    return {"setups": setups, "ncPrograms": [cam.ncPrograms.item(i).name for i in range(cam.ncPrograms.count)]}+        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}
addin/AdomBridge/commands/mechanical/schema.json+175−10
@@ -7701,18 +7701,28 @@   },   "cam_tools": {     "type": "object",-    "description": "List tools in Fusion's sample tool libraries (milling or hole making, metric), optionally of one type, with diameter in mm, so cam_job can name them.",+    "description": "List the tools of one of Fusion's tool libraries, with diameter, corner radius and flute length in mm: library milling or hole (Fusion's metric samples; milling by default), any sample or local library by its name, or document (the tools this document's CAM already uses). Filter by type, description (a substring) and diameter range. Every reply also lists the libraries cam_job can name.",     "properties": {       "library": {         "type": "string",-        "enum": [-          "milling",-          "hole"-        ]+        "description": "milling, hole, document, or a library name from the reply's libraries (Milling Tools (Inch), a local library, ...)."       },       "type": {         "type": "string"       },+      "description": {+        "type": "string"+      },+      "minDiameterMm": {+        "type": "number",+        "minimum": 0,+        "maximum": 1000+      },+      "maxDiameterMm": {+        "type": "number",+        "minimum": 0,+        "maximum": 1000+      },       "limit": {         "type": "integer",         "minimum": 1,@@ -7725,7 +7735,7 @@   },   "cam_job": {     "type": "object",-    "description": "Make a milling job: a setup on the given bodies, then operations in order (strategy face, adaptive, contour, parallel, scallop, pocket_clearing or drill), each with a tool from Fusion's sample library chosen by type and diameter (nearest) and optional Fusion parameter overrides by name. Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths, reports every operation's warnings and errors and the machining time, and, with post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...). Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace. Journaled.",+    "description": "Make a milling job: a setup on the given bodies, with optional stock (relative_box with offsets, fixed_box with a size, or solid stock bodies) and WCS origin (a stock or model box point, or the model origin); then operations in order: face, adaptive2d, pocket2d, contour2d, drill (cycle drill, peck or chip_break), adaptive, pocket_clearing, contour (3D contour), parallel or scallop, each with a tool from a Fusion library chosen by type, description, flutes and nearest diameter, and optional Fusion parameter overrides by name. 2D operations take faces (contour2d: their outside, inside or all loops; pocket2d and adaptive2d: pocket floors), else the outside of the setup bodies' silhouette or the bodies' pocket floors (upward faces below their top). Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths (an operation Fusion leaves untouched gets one more try) and reports every operation's status (generated, empty, error, outdated, suppressed, not_generated), warnings, errors and machining time. With post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...) into post.folder, named and numbered post.program; it does not post while an operation lacks a valid toolpath. Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace and says so. Journaled.",     "properties": {       "operationId": {         "type": "string"@@ -7741,6 +7751,107 @@           "type": "object"         }       },+      "stock": {+        "type": "object",+        "description": "relative_box (Fusion's default) takes offsetMm sides/top/bottom; fixed_box takes sizeMm [x, y, z]; solid takes stock bodies.",+        "properties": {+          "mode": {+            "type": "string",+            "enum": [+              "relative_box",+              "fixed_box",+              "solid"+            ]+          },+          "offsetMm": {+            "type": "object",+            "properties": {+              "sides": {+                "type": "number",+                "minimum": 0,+                "maximum": 1000+              },+              "top": {+                "type": "number",+                "minimum": 0,+                "maximum": 1000+              },+              "bottom": {+                "type": "number",+                "minimum": 0,+                "maximum": 1000+              }+            },+            "additionalProperties": false+          },+          "sizeMm": {+            "type": "array",+            "minItems": 3,+            "maxItems": 3,+            "items": {+              "type": "number",+              "exclusiveMinimum": 0,+              "maximum": 10000+            }+          },+          "bodies": {+            "type": "array",+            "minItems": 1,+            "maxItems": 32,+            "items": {+              "type": "object"+            }+          }+        },+        "additionalProperties": false+      },+      "wcs": {+        "type": "object",+        "description": "The work coordinate system's origin: a box point of the stock (stock_point) or of the model (model_point), top center by default (top 1 to 4 are the top corners at x-min y-min, x-max y-min, x-min y-max, x-max y-max), or the model origin. The axes stay the model's.",+        "properties": {+          "origin": {+            "type": "string",+            "enum": [+              "stock_point",+              "model_point",+              "model_origin"+            ]+          },+          "point": {+            "type": "string",+            "enum": [+              "top center",+              "top 1",+              "top 2",+              "top 3",+              "top 4",+              "top side 1",+              "top side 2",+              "top side 3",+              "top side 4",+              "center",+              "middle 1",+              "middle 2",+              "middle 3",+              "middle 4",+              "middle side 1",+              "middle side 2",+              "middle side 3",+              "middle side 4",+              "bottom center",+              "bottom 1",+              "bottom 2",+              "bottom 3",+              "bottom 4",+              "bottom side 1",+              "bottom side 2",+              "bottom side 3",+              "bottom side 4"+            ]+          }+        },+        "additionalProperties": false+      },       "operations": {         "type": "array",         "minItems": 1,@@ -7752,12 +7863,15 @@               "type": "string",               "enum": [                 "face",+                "adaptive2d",+                "pocket2d",+                "contour2d",+                "drill",                 "adaptive",+                "pocket_clearing",                 "contour",                 "parallel",-                "scallop",-                "pocket_clearing",-                "drill"+                "scallop"               ]             },             "name": {@@ -7766,6 +7880,10 @@             "tool": {               "type": "object",               "properties": {+                "library": {+                  "type": "string",+                  "description": "milling (default), hole (default for drills), document, or a library name cam_tools lists."+                },                 "type": {                   "type": "string",                   "description": "Tool type as Fusion names it: flat end mill, ball end mill, bull nose end mill, drill, chamfer mill, ..."@@ -7774,6 +7892,15 @@                   "type": "number",                   "exclusiveMinimum": 0,                   "maximum": 200+                },+                "description": {+                  "type": "string",+                  "description": "A substring of the tool's description, case-insensitive."+                },+                "flutes": {+                  "type": "integer",+                  "minimum": 1,+                  "maximum": 50                 }               },               "additionalProperties": false@@ -7781,6 +7908,39 @@             "parameters": {               "type": "object"             },+            "faces": {+              "type": "array",+              "minItems": 1,+              "maxItems": 64,+              "items": {+                "type": "object"+              },+              "description": "2D geometry: contour2d contours these faces' loops; pocket2d and adaptive2d clear these pocket floors. Without faces: the bodies' silhouette, or their pocket floors."+            },+            "loops": {+              "type": "string",+              "enum": [+                "outside",+                "inside",+                "all"+              ],+              "description": "contour2d: which loops of each face (or of the silhouette) to contour; outside by default."+            },+            "cycle": {+              "type": "string",+              "enum": [+                "drill",+                "peck",+                "chip_break"+              ],+              "description": "drill: Fusion's drilling (G81), deep-drilling (G83) or chip-breaking (G73) cycle."+            },+            "peckDepthMm": {+              "type": "number",+              "exclusiveMinimum": 0,+              "maximum": 1000,+              "description": "Peck depth for cycle peck or chip_break."+            },             "holeFaces": {               "type": "array",               "maxItems": 512,@@ -7805,7 +7965,12 @@             "type": "string"           },           "program": {-            "type": "string"+            "type": "string",+            "description": "Program name or number, and the file's name (FANUC and HAAS take a number, 1 to 9999)."+          },+          "comment": {+            "type": "string",+            "description": "Program comment, written after the program number."           }         },         "required": [
contracts/describe.json+348−18
@@ -4408,7 +4408,7 @@             "type": "object"           },           "cam_job": {-            "description": "Make a milling job: a setup on the given bodies, then operations in order (strategy face, adaptive, contour, parallel, scallop, pocket_clearing or drill), each with a tool from Fusion's sample library chosen by type and diameter (nearest) and optional Fusion parameter overrides by name. Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths, reports every operation's warnings and errors and the machining time, and, with post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...). Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace. Journaled.",+            "description": "Make a milling job: a setup on the given bodies, with optional stock (relative_box with offsets, fixed_box with a size, or solid stock bodies) and WCS origin (a stock or model box point, or the model origin); then operations in order: face, adaptive2d, pocket2d, contour2d, drill (cycle drill, peck or chip_break), adaptive, pocket_clearing, contour (3D contour), parallel or scallop, each with a tool from a Fusion library chosen by type, description, flutes and nearest diameter, and optional Fusion parameter overrides by name. 2D operations take faces (contour2d: their outside, inside or all loops; pocket2d and adaptive2d: pocket floors), else the outside of the setup bodies' silhouette or the bodies' pocket floors (upward faces below their top). Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths (an operation Fusion leaves untouched gets one more try) and reports every operation's status (generated, empty, error, outdated, suppressed, not_generated), warnings, errors and machining time. With post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...) into post.folder, named and numbered post.program; it does not post while an operation lacks a valid toolpath. Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace and says so. Journaled.",             "properties": {               "bodies": {                 "items": {@@ -4428,6 +4428,24 @@                 "items": {                   "additionalProperties": false,                   "properties": {+                    "cycle": {+                      "description": "drill: Fusion's drilling (G81), deep-drilling (G83) or chip-breaking (G73) cycle.",+                      "enum": [+                        "drill",+                        "peck",+                        "chip_break"+                      ],+                      "type": "string"+                    },+                    "faces": {+                      "description": "2D geometry: contour2d contours these faces' loops; pocket2d and adaptive2d clear these pocket floors. Without faces: the bodies' silhouette, or their pocket floors.",+                      "items": {+                        "type": "object"+                      },+                      "maxItems": 64,+                      "minItems": 1,+                      "type": "array"+                    },                     "holeFaces": {                       "items": {                         "type": "object"@@ -4435,32 +4453,63 @@                       "maxItems": 512,                       "type": "array"                     },+                    "loops": {+                      "description": "contour2d: which loops of each face (or of the silhouette) to contour; outside by default.",+                      "enum": [+                        "outside",+                        "inside",+                        "all"+                      ],+                      "type": "string"+                    },                     "name": {                       "type": "string"                     },                     "parameters": {                       "type": "object"                     },+                    "peckDepthMm": {+                      "description": "Peck depth for cycle peck or chip_break.",+                      "exclusiveMinimum": 0,+                      "maximum": 1000,+                      "type": "number"+                    },                     "strategy": {                       "enum": [                         "face",+                        "adaptive2d",+                        "pocket2d",+                        "contour2d",+                        "drill",                         "adaptive",+                        "pocket_clearing",                         "contour",                         "parallel",-                        "scallop",-                        "pocket_clearing",-                        "drill"+                        "scallop"                       ],                       "type": "string"                     },                     "tool": {                       "additionalProperties": false,                       "properties": {+                        "description": {+                          "description": "A substring of the tool's description, case-insensitive.",+                          "type": "string"+                        },                         "diameterMm": {                           "exclusiveMinimum": 0,                           "maximum": 200,                           "type": "number"                         },+                        "flutes": {+                          "maximum": 50,+                          "minimum": 1,+                          "type": "integer"+                        },+                        "library": {+                          "description": "milling (default), hole (default for drills), document, or a library name cam_tools lists.",+                          "type": "string"+                        },                         "type": {                           "description": "Tool type as Fusion names it: flat end mill, ball end mill, bull nose end mill, drill, chamfer mill, ...",                           "type": "string"@@ -4481,6 +4530,10 @@               "post": {                 "additionalProperties": false,                 "properties": {+                  "comment": {+                    "description": "Program comment, written after the program number.",+                    "type": "string"+                  },                   "folder": {                     "type": "string"                   },@@ -4488,6 +4541,7 @@                     "type": "string"                   },                   "program": {+                    "description": "Program name or number, and the file's name (FANUC and HAAS take a number, 1 to 9999).",                     "type": "string"                   }                 },@@ -4496,10 +4550,111 @@                 ],                 "type": "object"               },+              "stock": {+                "additionalProperties": false,+                "description": "relative_box (Fusion's default) takes offsetMm sides/top/bottom; fixed_box takes sizeMm [x, y, z]; solid takes stock bodies.",+                "properties": {+                  "bodies": {+                    "items": {+                      "type": "object"+                    },+                    "maxItems": 32,+                    "minItems": 1,+                    "type": "array"+                  },+                  "mode": {+                    "enum": [+                      "relative_box",+                      "fixed_box",+                      "solid"+                    ],+                    "type": "string"+                  },+                  "offsetMm": {+                    "additionalProperties": false,+                    "properties": {+                      "bottom": {+                        "maximum": 1000,+                        "minimum": 0,+                        "type": "number"+                      },+                      "sides": {+                        "maximum": 1000,+                        "minimum": 0,+                        "type": "number"+                      },+                      "top": {+                        "maximum": 1000,+                        "minimum": 0,+                        "type": "number"+                      }+                    },+                    "type": "object"+                  },+                  "sizeMm": {+                    "items": {+                      "exclusiveMinimum": 0,+                      "maximum": 10000,+                      "type": "number"+                    },+                    "maxItems": 3,+                    "minItems": 3,+                    "type": "array"+                  }+                },+                "type": "object"+              },               "timeoutSeconds": {                 "maximum": 3600,                 "minimum": 10,                 "type": "number"+              },+              "wcs": {+                "additionalProperties": false,+                "description": "The work coordinate system's origin: a box point of the stock (stock_point) or of the model (model_point), top center by default (top 1 to 4 are the top corners at x-min y-min, x-max y-min, x-min y-max, x-max y-max), or the model origin. The axes stay the model's.",+                "properties": {+                  "origin": {+                    "enum": [+                      "stock_point",+                      "model_point",+                      "model_origin"+                    ],+                    "type": "string"+                  },+                  "point": {+                    "enum": [+                      "top center",+                      "top 1",+                      "top 2",+                      "top 3",+                      "top 4",+                      "top side 1",+                      "top side 2",+                      "top side 3",+                      "top side 4",+                      "center",+                      "middle 1",+                      "middle 2",+                      "middle 3",+                      "middle 4",+                      "middle side 1",+                      "middle side 2",+                      "middle side 3",+                      "middle side 4",+                      "bottom center",+                      "bottom 1",+                      "bottom 2",+                      "bottom 3",+                      "bottom 4",+                      "bottom side 1",+                      "bottom side 2",+                      "bottom side 3",+                      "bottom side 4"+                    ],+                    "type": "string"+                  }+                },+                "type": "object"               }             },             "required": [@@ -4513,13 +4668,13 @@             "type": "object"           },           "cam_tools": {-            "description": "List tools in Fusion's sample tool libraries (milling or hole making, metric), optionally of one type, with diameter in mm, so cam_job can name them.",+            "description": "List the tools of one of Fusion's tool libraries, with diameter, corner radius and flute length in mm: library milling or hole (Fusion's metric samples; milling by default), any sample or local library by its name, or document (the tools this document's CAM already uses). Filter by type, description (a substring) and diameter range. Every reply also lists the libraries cam_job can name.",             "properties": {+              "description": {+                "type": "string"+              },               "library": {-                "enum": [-                  "milling",-                  "hole"-                ],+                "description": "milling, hole, document, or a library name from the reply's libraries (Milling Tools (Inch), a local library, ...).",                 "type": "string"               },               "limit": {@@ -4527,6 +4682,16 @@                 "minimum": 1,                 "type": "integer"               },+              "maxDiameterMm": {+                "maximum": 1000,+                "minimum": 0,+                "type": "number"+              },+              "minDiameterMm": {+                "maximum": 1000,+                "minimum": 0,+                "type": "number"+              },               "type": {                 "type": "string"               }@@ -12286,7 +12451,7 @@             "type": "object"           },           "cam_job": {-            "description": "Make a milling job: a setup on the given bodies, then operations in order (strategy face, adaptive, contour, parallel, scallop, pocket_clearing or drill), each with a tool from Fusion's sample library chosen by type and diameter (nearest) and optional Fusion parameter overrides by name. Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths, reports every operation's warnings and errors and the machining time, and, with post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...). Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace. Journaled.",+            "description": "Make a milling job: a setup on the given bodies, with optional stock (relative_box with offsets, fixed_box with a size, or solid stock bodies) and WCS origin (a stock or model box point, or the model origin); then operations in order: face, adaptive2d, pocket2d, contour2d, drill (cycle drill, peck or chip_break), adaptive, pocket_clearing, contour (3D contour), parallel or scallop, each with a tool from a Fusion library chosen by type, description, flutes and nearest diameter, and optional Fusion parameter overrides by name. 2D operations take faces (contour2d: their outside, inside or all loops; pocket2d and adaptive2d: pocket floors), else the outside of the setup bodies' silhouette or the bodies' pocket floors (upward faces below their top). Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths (an operation Fusion leaves untouched gets one more try) and reports every operation's status (generated, empty, error, outdated, suppressed, not_generated), warnings, errors and machining time. With post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...) into post.folder, named and numbered post.program; it does not post while an operation lacks a valid toolpath. Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace and says so. Journaled.",             "properties": {               "bodies": {                 "items": {@@ -12306,6 +12471,24 @@                 "items": {                   "additionalProperties": false,                   "properties": {+                    "cycle": {+                      "description": "drill: Fusion's drilling (G81), deep-drilling (G83) or chip-breaking (G73) cycle.",+                      "enum": [+                        "drill",+                        "peck",+                        "chip_break"+                      ],+                      "type": "string"+                    },+                    "faces": {+                      "description": "2D geometry: contour2d contours these faces' loops; pocket2d and adaptive2d clear these pocket floors. Without faces: the bodies' silhouette, or their pocket floors.",+                      "items": {+                        "type": "object"+                      },+                      "maxItems": 64,+                      "minItems": 1,+                      "type": "array"+                    },                     "holeFaces": {                       "items": {                         "type": "object"@@ -12313,32 +12496,63 @@                       "maxItems": 512,                       "type": "array"                     },+                    "loops": {+                      "description": "contour2d: which loops of each face (or of the silhouette) to contour; outside by default.",+                      "enum": [+                        "outside",+                        "inside",+                        "all"+                      ],+                      "type": "string"+                    },                     "name": {                       "type": "string"                     },                     "parameters": {                       "type": "object"                     },+                    "peckDepthMm": {+                      "description": "Peck depth for cycle peck or chip_break.",+                      "exclusiveMinimum": 0,+                      "maximum": 1000,+                      "type": "number"+                    },                     "strategy": {                       "enum": [                         "face",+                        "adaptive2d",+                        "pocket2d",+                        "contour2d",+                        "drill",                         "adaptive",+                        "pocket_clearing",                         "contour",                         "parallel",-                        "scallop",-                        "pocket_clearing",-                        "drill"+                        "scallop"                       ],                       "type": "string"                     },                     "tool": {                       "additionalProperties": false,                       "properties": {+                        "description": {+                          "description": "A substring of the tool's description, case-insensitive.",+                          "type": "string"+                        },                         "diameterMm": {                           "exclusiveMinimum": 0,                           "maximum": 200,                           "type": "number"                         },+                        "flutes": {+                          "maximum": 50,+                          "minimum": 1,+                          "type": "integer"+                        },+                        "library": {+                          "description": "milling (default), hole (default for drills), document, or a library name cam_tools lists.",+                          "type": "string"+                        },                         "type": {                           "description": "Tool type as Fusion names it: flat end mill, ball end mill, bull nose end mill, drill, chamfer mill, ...",                           "type": "string"@@ -12359,6 +12573,10 @@               "post": {                 "additionalProperties": false,                 "properties": {+                  "comment": {+                    "description": "Program comment, written after the program number.",+                    "type": "string"+                  },                   "folder": {                     "type": "string"                   },@@ -12366,6 +12584,7 @@                     "type": "string"                   },                   "program": {+                    "description": "Program name or number, and the file's name (FANUC and HAAS take a number, 1 to 9999).",                     "type": "string"                   }                 },@@ -12374,10 +12593,111 @@                 ],                 "type": "object"               },+              "stock": {+                "additionalProperties": false,+                "description": "relative_box (Fusion's default) takes offsetMm sides/top/bottom; fixed_box takes sizeMm [x, y, z]; solid takes stock bodies.",+                "properties": {+                  "bodies": {+                    "items": {+                      "type": "object"+                    },+                    "maxItems": 32,+                    "minItems": 1,+                    "type": "array"+                  },+                  "mode": {+                    "enum": [+                      "relative_box",+                      "fixed_box",+                      "solid"+                    ],+                    "type": "string"+                  },+                  "offsetMm": {+                    "additionalProperties": false,+                    "properties": {+                      "bottom": {+                        "maximum": 1000,+                        "minimum": 0,+                        "type": "number"+                      },+                      "sides": {+                        "maximum": 1000,+                        "minimum": 0,+                        "type": "number"+                      },+                      "top": {+                        "maximum": 1000,+                        "minimum": 0,+                        "type": "number"+                      }+                    },+                    "type": "object"+                  },+                  "sizeMm": {+                    "items": {+                      "exclusiveMinimum": 0,+                      "maximum": 10000,+                      "type": "number"+                    },+                    "maxItems": 3,+                    "minItems": 3,+                    "type": "array"+                  }+                },+                "type": "object"+              },               "timeoutSeconds": {                 "maximum": 3600,                 "minimum": 10,                 "type": "number"+              },+              "wcs": {+                "additionalProperties": false,+                "description": "The work coordinate system's origin: a box point of the stock (stock_point) or of the model (model_point), top center by default (top 1 to 4 are the top corners at x-min y-min, x-max y-min, x-min y-max, x-max y-max), or the model origin. The axes stay the model's.",+                "properties": {+                  "origin": {+                    "enum": [+                      "stock_point",+                      "model_point",+                      "model_origin"+                    ],+                    "type": "string"+                  },+                  "point": {+                    "enum": [+                      "top center",+                      "top 1",+                      "top 2",+                      "top 3",+                      "top 4",+                      "top side 1",+                      "top side 2",+                      "top side 3",+                      "top side 4",+                      "center",+                      "middle 1",+                      "middle 2",+                      "middle 3",+                      "middle 4",+                      "middle side 1",+                      "middle side 2",+                      "middle side 3",+                      "middle side 4",+                      "bottom center",+                      "bottom 1",+                      "bottom 2",+                      "bottom 3",+                      "bottom 4",+                      "bottom side 1",+                      "bottom side 2",+                      "bottom side 3",+                      "bottom side 4"+                    ],+                    "type": "string"+                  }+                },+                "type": "object"               }             },             "required": [@@ -12391,13 +12711,13 @@             "type": "object"           },           "cam_tools": {-            "description": "List tools in Fusion's sample tool libraries (milling or hole making, metric), optionally of one type, with diameter in mm, so cam_job can name them.",+            "description": "List the tools of one of Fusion's tool libraries, with diameter, corner radius and flute length in mm: library milling or hole (Fusion's metric samples; milling by default), any sample or local library by its name, or document (the tools this document's CAM already uses). Filter by type, description (a substring) and diameter range. Every reply also lists the libraries cam_job can name.",             "properties": {+              "description": {+                "type": "string"+              },               "library": {-                "enum": [-                  "milling",-                  "hole"-                ],+                "description": "milling, hole, document, or a library name from the reply's libraries (Milling Tools (Inch), a local library, ...).",                 "type": "string"               },               "limit": {@@ -12405,6 +12725,16 @@                 "minimum": 1,                 "type": "integer"               },+              "maxDiameterMm": {+                "maximum": 1000,+                "minimum": 0,+                "type": "number"+              },+              "minDiameterMm": {+                "maximum": 1000,+                "minimum": 0,+                "type": "number"+              },               "type": {                 "type": "string"               }
docs/MECHANICAL.md+28−11
@@ -380,23 +380,40 @@ Create/insert transforms use parent-component coordinates. Creation, insertion a  ## CAM: toolpaths and G-code -`cam_tools` lists Fusion's sample tool libraries (`library` `milling` or `hole`, metric), optionally of one `type`, with diameters in mm.+`cam_tools` lists the tools of one of Fusion's tool libraries, with diameter, corner radius and flute length in mm. `library` is `milling` or `hole` (Fusion's metric samples; `milling` by default), any sample or local library by its name (`Milling Tools (Inch)`, ...), or `document` (the tools the document's CAM already uses). It filters by `type`, `description` (a substring) and `minDiameterMm` / `maxDiameterMm`, and every reply lists the `libraries` a job can name. -`cam_job` makes a milling job in one journaled call. It takes the `bodies` to machine and `operations` in order, each a `strategy`:+`cam_job` makes a milling job in one journaled call.++**Setup.** A setup on the given `bodies`.+- `stock` is Fusion's relative box unless it says otherwise: `{"mode": "relative_box", "offsetMm": {"sides": 2, "top": 1, "bottom": 0}}` adds stock round the model, `{"mode": "fixed_box", "sizeMm": [110, 70, 15]}` sets a fixed box, and `{"mode": "solid", "bodies": [...]}` machines from stock bodies.+- `wcs` puts the origin at a box point of the stock (`stock_point`) or of the model (`model_point`), `top center` by default. `top 1` to `top 4` are the top corners at x-min y-min, x-max y-min, x-min y-max and x-max y-max; Fusion also has `top side 1..4`, `center`, `middle ...` and `bottom ...`. `model_origin` keeps the model's origin. The axes stay the model's.+- The reply's `setup` reads back what Fusion built: the stock mode, size and box and the WCS origin and axes, in mm and model coordinates. Fusion keeps the stock box relative to the WCS (`stockXLow` and so on, in cm) and `Setup.workCoordinateSystem` in mm; the reply turns both into model coordinates.++**Operations**, in order, each a `strategy`: - `face`;-- `adaptive`, Fusion's 3D adaptive roughing;-- `contour`, 3D contour finishing;-- `parallel` and `scallop`;-- `pocket_clearing`;-- `drill`.+- `adaptive2d` and `pocket2d`: the pocket floors given as `faces`, else the setup bodies' pocket floors (upward planar faces below each body's top). Fusion's own pocket recognition found the pocket on a test plate but generated an empty toolpath, so it is not used;+- `contour2d`: the `loops` (`outside` by default, `inside` or `all`) of the given `faces`, else of the setup bodies' silhouette;+- `drill`: the bodies' holes of the drill's diameter unless `holeFaces` are given, with `cycle` `drill` (Fusion's drilling, G81 on FANUC), `peck` (deep drilling, G83) or `chip_break` (G73), and `peckDepthMm` (Fusion's `peckingDepth`, which those cycles enable; the older `incrementalDepth` stays disabled);+- `adaptive` (3D adaptive clearing), `pocket_clearing`, `contour` (3D contour), `parallel`, `scallop`.++Each tool comes from a library by `type` (a flat end mill by default, a drill for `drill`), an optional `description` substring and `flutes`, and the nearest `diameterMm` (a tie goes to the smaller tool); `tool.library` names any library `cam_tools` lists. `parameters` override Fusion's operation parameters by name, and a value Fusion flags is refused rather than kept. Each operation in the reply names its tool and library and where its geometry came from (`faces`, the bodies' pocket floors or silhouette, or the holes found, with counts).++**Toolpaths.** The job generates them, gives an operation Fusion left untouched (no toolpath, no warning, no error) one more try on its own (`generated.retried`), and reports every operation's `status`: `generated` (a valid toolpath), `empty` (generation ran and produced nothing), `error`, `outdated` (the model has changed since), `suppressed` or `not_generated`, with Fusion's `warnings` and `errors` line by line and its machining time. `generated` counts the operations with a toolpath and says whether the job is `complete`. `machining` is Fusion's estimate split into feed, rapid and tool change time. On Fusion 2705 `getMachiningTime` ignores the feed scale and rapid rate it is given (a face pass timed the same with feed scale 1 or 100 and rapids of 500 or 8.33 cm/s) and times rapids at its own rate, which the reply reports as `rapidMmPerMin` (30,000 seen). The job used to pass 500 for the rapid rate, meaning mm/min / 10, while the reference gives cm/s.++**Post.** With `post` (`machine`, `folder`, `program`, `comment`) the job posts G-code through a post from Fusion's library matched by name (`FANUC`, `Grbl`, `HAAS - Next Generation Control`, ...) into `folder`, as `<program>.nc` numbered `program`, with the comment after the number. FANUC and HAAS take a number from 1 to 9999. The job does not post while any operation lacks a valid toolpath, because Fusion's post leaves such an operation out of the program without a word: the reply says `posted: false` and names the operations. A posted reply carries the file, its first lines, `programInHeader` (the header's program number is the one asked for), the operations posted, and `ncProgramKept` (the name and folder the NC program held when it posted).++A failed job deletes its setup and the tools it brought into the document's tool library, and a failed post deletes its NC program, so nothing half-made is left. A job that runs but leaves an operation empty or failed keeps its setup, so a person can open it in Fusion. -Each operation's tool comes from the sample library by `type` and nearest `diameterMm` (a tie goes to the smaller tool). Optional `parameters` override Fusion's operation parameters by name. Drill picks the bodies' concave cylindrical faces of the drill's diameter unless `holeFaces` are given.+**The Manufacture workspace.** A document has no CAM product until the Manufacture workspace has been opened in it once: on Fusion 2705 `Products.itemByProductType("CAMProductType")` raises `3 : failed to find product`, and `Products` has no way to add one. So the first job in a document opens Manufacture for the moment Fusion takes to create the product (0.6 to 1.6 s measured), then makes the previous workspace active again, also when that fails, and reports the round trip as `workspace` (`switched`, `from`, `restored`, `now`). The switch shows only in the target document, which is the active one; afterwards the active product is the design again. Later jobs in that document, `cam_inspect` and `cam_tools` never change the workspace. -The job generates the toolpaths and reports each operation's toolpath state, warnings and errors, plus the machining time. With `post` (`machine`, `folder`, `program`) it posts G-code through a post from Fusion's library matched by name: `FANUC`, `Grbl`, `HAAS - Next Generation Control`, ... Fusion writes posted files to its default NC folder whatever folder is asked for, so the job moves the file into `post.folder`.+`cam_inspect` reads the document's setups (stock box, WCS, and every operation's status, warnings, errors and tool) and its NC programs (post, program, file name, folder). -A failed job deletes its setup, so nothing half-made is left. A document has no CAM until the Manufacture workspace has been opened once, so the first job opens it and returns to the user's workspace. `cam_inspect` reads the document's setups, operations and NC programs.+**The program number and the output folder.** When an NC program's operations come from one setup, Fusion copies the setup's `job_programName` and `job_programComment` into the program's `nc_program_name` and `nc_program_comment` (`NCProgramInput.operations` in Fusion's API reference). The job used to set the name before the operations, so every FANUC program came out as O1001, the setup's default. The name and comment now go on the setup as well, and on the NC program after its operations and again after its post is chosen. The output folder was ignored because of its backslashes: a folder written `C:\Users\...` reads back as unspecified and the file lands in Documents/Fusion 360/NC Programs, while the same folder written `C:/Users/...` is used, set on the NC program input or on the program after its post. The job writes it with forward slashes. If Fusion still writes a file to its default NC folder, the job moves it into `post.folder` and says `movedFromFusionFolder`; a file of the same name already in `post.folder` is replaced (`replacedExisting`). Probed side by side on Fusion 2705: the name set before the operations gave O1001, after them O2002, the setup's `job_programName` O2003; the backslash folder landed in the default folder, both forward-slash variants in the requested one. -Live on Fusion 2705, a plate with a pocket and four 5 mm holes gave valid toolpaths for every operation (face, adaptive, contour, drill) and a 1,901-line FANUC program with three tools and a G81 drilling cycle. Known gap: the FANUC program number stays O1001 (the file takes the requested name).+Live on Fusion 2705, on a 100 x 60 x 12 mm plate with a 40 x 30 x 5 mm pocket and four 5 mm through holes (scratch documents, closed afterwards):+- a 2D job (relative stock with 2 mm sides and 1 mm top, WCS at the stock's top front-left corner; face, 2D adaptive on the pocket floor it found, 2D contour round the silhouette, peck drilling) generated all four toolpaths in 2.7 s, one of them on the retry, and posted a 959-line FANUC program headed `O2001 (CAM CHECK 2D)` into the requested folder, with `G98 G83 X12. Y12. Z-13. R4. Q2.` at the holes, measured from the stock corner. Fusion estimates 206.8 s;+- a 3D job (fixed 104 x 64 x 14 mm stock, WCS at the model origin; 3D adaptive, 2D pocket on the given floor, 3D contour, and parallel and scallop with a 6 mm ball end mill) generated five toolpaths in 3.2 s and posted an 8,537-line HAAS program headed `O03002 (CAM CHECK 3D)`;+- 3D pocket clearing generated; a 2D contour of loops the face does not have came back `empty` with Fusion's warning, and that job did not post; a 20 mm drill on a part with 5 mm holes was refused and left neither a setup nor its drill behind.  ## McMaster and purchased CAD 
rust/core/tests/golden/mech_capabilities.json+175−10
@@ -7716,18 +7716,28 @@    },    "cam_tools": {     "type": "object",-    "description": "List tools in Fusion's sample tool libraries (milling or hole making, metric), optionally of one type, with diameter in mm, so cam_job can name them.",+    "description": "List the tools of one of Fusion's tool libraries, with diameter, corner radius and flute length in mm: library milling or hole (Fusion's metric samples; milling by default), any sample or local library by its name, or document (the tools this document's CAM already uses). Filter by type, description (a substring) and diameter range. Every reply also lists the libraries cam_job can name.",     "properties": {      "library": {       "type": "string",-      "enum": [-       "milling",-       "hole"-      ]+      "description": "milling, hole, document, or a library name from the reply's libraries (Milling Tools (Inch), a local library, ...)."      },      "type": {       "type": "string"      },+     "description": {+      "type": "string"+     },+     "minDiameterMm": {+      "type": "number",+      "minimum": 0,+      "maximum": 1000+     },+     "maxDiameterMm": {+      "type": "number",+      "minimum": 0,+      "maximum": 1000+     },      "limit": {       "type": "integer",       "minimum": 1,@@ -7740,7 +7750,7 @@    },    "cam_job": {     "type": "object",-    "description": "Make a milling job: a setup on the given bodies, then operations in order (strategy face, adaptive, contour, parallel, scallop, pocket_clearing or drill), each with a tool from Fusion's sample library chosen by type and diameter (nearest) and optional Fusion parameter overrides by name. Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths, reports every operation's warnings and errors and the machining time, and, with post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...). Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace. Journaled.",+    "description": "Make a milling job: a setup on the given bodies, with optional stock (relative_box with offsets, fixed_box with a size, or solid stock bodies) and WCS origin (a stock or model box point, or the model origin); then operations in order: face, adaptive2d, pocket2d, contour2d, drill (cycle drill, peck or chip_break), adaptive, pocket_clearing, contour (3D contour), parallel or scallop, each with a tool from a Fusion library chosen by type, description, flutes and nearest diameter, and optional Fusion parameter overrides by name. 2D operations take faces (contour2d: their outside, inside or all loops; pocket2d and adaptive2d: pocket floors), else the outside of the setup bodies' silhouette or the bodies' pocket floors (upward faces below their top). Drill picks the bodies' holes of the drill's diameter unless holeFaces are given. Generates the toolpaths (an operation Fusion leaves untouched gets one more try) and reports every operation's status (generated, empty, error, outdated, suppressed, not_generated), warnings, errors and machining time. With post, writes G-code through a post from Fusion's library (FANUC, Grbl, HAAS - Next Generation Control, ...) into post.folder, named and numbered post.program; it does not post while an operation lacks a valid toolpath. Opens the Manufacture workspace once if the document has no CAM yet, then returns to the user's workspace and says so. Journaled.",     "properties": {      "operationId": {       "type": "string"@@ -7756,6 +7766,107 @@        "type": "object"       }      },+     "stock": {+      "type": "object",+      "description": "relative_box (Fusion's default) takes offsetMm sides/top/bottom; fixed_box takes sizeMm [x, y, z]; solid takes stock bodies.",+      "properties": {+       "mode": {+        "type": "string",+        "enum": [+         "relative_box",+         "fixed_box",+         "solid"+        ]+       },+       "offsetMm": {+        "type": "object",+        "properties": {+         "sides": {+          "type": "number",+          "minimum": 0,+          "maximum": 1000+         },+         "top": {+          "type": "number",+          "minimum": 0,+          "maximum": 1000+         },+         "bottom": {+          "type": "number",+          "minimum": 0,+          "maximum": 1000+         }+        },+        "additionalProperties": false+       },+       "sizeMm": {+        "type": "array",+        "minItems": 3,+        "maxItems": 3,+        "items": {+         "type": "number",+         "exclusiveMinimum": 0,+         "maximum": 10000+        }+       },+       "bodies": {+        "type": "array",+        "minItems": 1,+        "maxItems": 32,+        "items": {+         "type": "object"+        }+       }+      },+      "additionalProperties": false+     },+     "wcs": {+      "type": "object",+      "description": "The work coordinate system's origin: a box point of the stock (stock_point) or of the model (model_point), top center by default (top 1 to 4 are the top corners at x-min y-min, x-max y-min, x-min y-max, x-max y-max), or the model origin. The axes stay the model's.",+      "properties": {+       "origin": {+        "type": "string",+        "enum": [+         "stock_point",+         "model_point",+         "model_origin"+        ]+       },+       "point": {+        "type": "string",+        "enum": [+         "top center",+         "top 1",+         "top 2",+         "top 3",+         "top 4",+         "top side 1",+         "top side 2",+         "top side 3",+         "top side 4",+         "center",+         "middle 1",+         "middle 2",+         "middle 3",+         "middle 4",+         "middle side 1",+         "middle side 2",+         "middle side 3",+         "middle side 4",+         "bottom center",+         "bottom 1",+         "bottom 2",+         "bottom 3",+         "bottom 4",+         "bottom side 1",+         "bottom side 2",+         "bottom side 3",+         "bottom side 4"+        ]+       }+      },+      "additionalProperties": false+     },      "operations": {       "type": "array",       "minItems": 1,@@ -7767,12 +7878,15 @@          "type": "string",          "enum": [           "face",+          "adaptive2d",+          "pocket2d",+          "contour2d",+          "drill",           "adaptive",+          "pocket_clearing",           "contour",           "parallel",-          "scallop",-          "pocket_clearing",-          "drill"+          "scallop"          ]         },         "name": {@@ -7781,6 +7895,10 @@         "tool": {          "type": "object",          "properties": {+          "library": {+           "type": "string",+           "description": "milling (default), hole (default for drills), document, or a library name cam_tools lists."+          },           "type": {            "type": "string",            "description": "Tool type as Fusion names it: flat end mill, ball end mill, bull nose end mill, drill, chamfer mill, ..."@@ -7789,6 +7907,15 @@            "type": "number",            "exclusiveMinimum": 0,            "maximum": 200+          },+          "description": {+           "type": "string",+           "description": "A substring of the tool's description, case-insensitive."+          },+          "flutes": {+           "type": "integer",+           "minimum": 1,+           "maximum": 50           }          },          "additionalProperties": false@@ -7796,6 +7923,39 @@         "parameters": {          "type": "object"         },+        "faces": {+         "type": "array",+         "minItems": 1,+         "maxItems": 64,+         "items": {+          "type": "object"+         },+         "description": "2D geometry: contour2d contours these faces' loops; pocket2d and adaptive2d clear these pocket floors. Without faces: the bodies' silhouette, or their pocket floors."+        },+        "loops": {+         "type": "string",+         "enum": [+          "outside",+          "inside",+          "all"+         ],+         "description": "contour2d: which loops of each face (or of the silhouette) to contour; outside by default."+        },+        "cycle": {+         "type": "string",+         "enum": [+          "drill",+          "peck",+          "chip_break"+         ],+         "description": "drill: Fusion's drilling (G81), deep-drilling (G83) or chip-breaking (G73) cycle."+        },+        "peckDepthMm": {+         "type": "number",+         "exclusiveMinimum": 0,+         "maximum": 1000,+         "description": "Peck depth for cycle peck or chip_break."+        },         "holeFaces": {          "type": "array",          "maxItems": 512,@@ -7820,7 +7980,12 @@         "type": "string"        },        "program": {-        "type": "string"+        "type": "string",+        "description": "Program name or number, and the file's name (FANUC and HAAS take a number, 1 to 9999)."+       },+       "comment": {+        "type": "string",+        "description": "Program comment, written after the program number."        }       },       "required": [
rust/core/tests/golden/mech_validation.json
@@ -1 +1 @@