← Commit history
CHANGELOG.md+26
@@ -3,6 +3,32 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.22 (unreleased)++- `fusion_variants` (the standard's variants): a design's assembly variants, each with its not-fitted parts and+  alternate values, read from the schematic. Making, changing or removing one is refused with not_supported: Fusion+  2706 takes EAGLE's VARIANT from a text command (any form, schematic or board) and changes nothing.+- `fusion_silkscreen_prep` (the standard's silkscreen_prep, in Fusion's terms): designators on copper moved off it where+  they fit (the engine's silk_designators), counted before and after; loose silk outside the board listed. A footprint's+  own silk lines are its library package's and cannot be clipped on one board: the fab's CAM clips silk at mask openings.++From Drew Owens (PR #27): CAM from the typed mechanical layer, and exploded renders that work on big assemblies.++- `cam_job` makes a milling job in one journaled call:+  - a setup on the given bodies, and operations in order (`face`, `adaptive`, `contour`, `parallel`, `scallop`, `pocket_clearing`, `drill`);+  - tools from Fusion's sample library by type and nearest diameter, with optional parameter overrides by name;+  - drilling finds the bodies' holes of the drill's diameter unless `holeFaces` are given;+  - it generates the toolpaths, reports each 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, ...);+  - a failed job deletes its setup.++  `cam_tools` lists the library's tools, and `cam_inspect` reads a document's setups and NC programs. Live: a pocketed, drilled plate gave+  valid face, adaptive, contour and drill toolpaths and a 1,901-line FANUC program. Known gap: the FANUC O-number stays O1001.+- `exploded_view_render` captures only the moved occurrences and their parents (children follow), so a 4,579-occurrence assembly+  renders instead of hitting the 256-instance budget. It checks home poses within 0.01 mm (`restoredWithinMm`) instead of exact float+  equality, which flagged a clean revert on a big assembly as `restore_failed`.+- 67 typed operations; the add-in is 1.0.27, and the exe embeds the new `schema.json`. 341 verbs.+ ## 1.13.21 (2026-10-29)  - `fusion_replicate_layout` (the standard's replicate_layout): one channel's placement copied to matching channels round
addin/AdomBridge/AdomBridge.manifest+1−1
@@ -6,7 +6,7 @@     "description": {         "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."     },-    "version": "1.0.26",+    "version": "1.0.27",     "runOnStartup": true,     "supportedOS": "windows" }\ No newline at end of file
addin/AdomBridge/commands/mechanical/__init__.py+3−2
@@ -14,7 +14,7 @@ import adsk.core from .core import SESSION_ID, MechanicalError, document_identity, require from .operations import OPERATIONS, WRITES from .journal import Journal, JournalError-from . import handoff, transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview, efficiency, exploded_render, render_image+from . import handoff, transactions, sketches, features, assemblies, purchased, bill_of_materials, geometry, sketch_edits, regular_joints, motion_links, configurations, render_scene, exploded_view, exploded_preview, efficiency, exploded_render, render_image, cam  for _name, _handler in {**sketches.OPERATIONS, **features.OPERATIONS, **assemblies.OPERATIONS,                         **geometry.OPERATIONS, **sketch_edits.OPERATIONS, **regular_joints.OPERATIONS, **motion_links.OPERATIONS}.items():@@ -35,7 +35,8 @@ OPERATIONS.update(bill_of_materials.OPERATIONS) OPERATIONS.update(efficiency.OPERATIONS) OPERATIONS.update(exploded_render.OPERATIONS) OPERATIONS.update(render_image.OPERATIONS)-WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES | exploded_render.WRITES | render_image.WRITES)+OPERATIONS.update(cam.OPERATIONS)+WRITES.update(purchased.WRITES | bill_of_materials.WRITES | efficiency.WRITES | exploded_render.WRITES | render_image.WRITES | cam.WRITES) OPERATIONS["transaction_probe"] = transactions.probe WRITES.add("transaction_probe") 
addin/AdomBridge/commands/mechanical/cam.pyadded+224
@@ -0,0 +1,224 @@+"""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."""+import os+from pathlib import Path+import threading+import time++import adsk.core+import adsk.fusion+try:+    import adsk.cam  # always present inside Fusion; offline tools load this package without it+except ImportError:+    pass++from .core import active, require, resolve, target++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)"}+RAPID_MM_PER_MIN = 5000.0+TOOL_CHANGE_SECONDS = 15.0+++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."""+    try:+        return adsk.cam.CAM.cast(doc.products.itemByProductType("CAMProductType"))+    except RuntimeError:+        pass+    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+++def library_tools(kind):+    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)]+++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 pick_tool(spec, strategy):+    """The library tool of the requested type 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)+    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))+++def hole_faces(bodies, diameter_mm, tolerance_mm=0.1):+    """Concave cylindrical faces of the setup's bodies with the drill's diameter: the holes to drill."""+    out = []+    for body in bodies:+        for face in body.faces:+            geometry = face.geometry+            if geometry.objectType == adsk.core.Cylinder.classType() and abs(geometry.radius * 20 - diameter_mm) <= tolerance_mm:+                normal = face.evaluator.getNormalAtPoint(face.pointOnFace)[1]+                to_axis = geometry.origin.vectorTo(face.pointOnFace)+                if normal.dotProduct(to_axis) < 0:   # pointing at the axis: a hole, not a boss+                    out.append(face)+    return out+++def 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 cam_tools(app, args):+    kind = args.get("library", "milling")+    tools = [tool_data(t) for t in library_tools(kind)]+    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"]})}+++def cam_job(app, args):+    require(threading.current_thread() is threading.main_thread(), "wrong_thread", "CAM requires the main thread.")+    doc, design, value = target(app, args, True, model_write=False)+    bodies = [resolve(design, value, ref, ("BRepBody",)) for ref in args["bodies"]]+    require(bodies, "invalid_selection", "A job needs at least one body.")+    cam = cam_product(app, doc)+    sinput = cam.setups.createInput(adsk.cam.OperationTypes.MillingOperation)+    sinput.models = 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)+    except Exception:+        # never leave a half-made setup behind: a failed job changes nothing+        try:+            setup.deleteMe()+        except Exception:+            pass+        raise+++def build_job(app, cam, setup, design, value, bodies, args):+    created, notes = [], []+    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))+        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)+        op = setup.operations.add(oinput)+        if spec.get("name"):+            op.name = spec["name"]+        if strategy == "drill":+            dia = tool_data(tool)["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)})+    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)+    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],+              "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)}}+    post = args.get("post")+    if post:+        result["post"] = post_program(cam, setup, post, started_at=time.time())+    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")+    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)+    program = str(spec.get("program", "1001"))+    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]+    nc = cam.ncPrograms.add(ninput)+    nc.postConfiguration = config+    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():+        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]}+++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."}+    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)]}+++OPERATIONS = {"cam_tools": cam_tools, "cam_job": cam_job, "cam_inspect": cam_inspect}+WRITES = {"cam_job"}
addin/AdomBridge/commands/mechanical/exploded_render.py+60−6
@@ -9,8 +9,8 @@ import threading import adsk.core import adsk.fusion -from .core import SESSION_ID, require, target-from .exploded_view import capture, compile_steps, matrix, request_arguments, sample_plan+from .core import SESSION_ID, concrete, digest, identity, ref, require, resolve, target+from .exploded_view import MAX_INSTANCES, compile_steps, matrix, request_arguments, sample_plan, text  MAX_FRAMES = 3600 # how far a part may sit from its planned pose before it counts as held back (mm, and matrix terms)@@ -27,6 +27,46 @@ def to_matrix(pose):     return matrix  +def moved_scope(design, value, steps):+    """Full paths of the occurrences the steps move, and of their ancestors (for inherited motion).+    Children of a moved occurrence follow it by themselves, so they are not tracked."""+    scope = set()+    for step in steps:+        for move in step.get("moves") or []:+            path = text(resolve(design, value, move["occurrence"], ("Occurrence",)).fullPathName)+            parts = path.split("+")+            scope.update("+".join(parts[:n]) for n in range(1, len(parts) + 1))+    require(len(scope) <= MAX_INSTANCES, "plan_budget", "The steps move more than %d occurrences (with their parents)." % MAX_INSTANCES)+    return scope+++def capture(design, value, scope):+    """The plan's instance rows for just the occurrences in scope, parents first. A big assembly+    (thousands of occurrences) only pays for the parts an animation moves."""+    found = {}+    every = design.rootComponent.allOccurrences+    for i in range(every.count):+        occurrence = concrete(every.item(i))+        path = occurrence.fullPathName+        if path in scope:+            require(path not in found, "ambiguous_context", "Every root-context occurrence needs a unique full path.")+            found[path] = occurrence+    require(set(found) == scope, "invalid_context", "A moved occurrence or one of its parents is no longer in the design.")+    rows, handles = [], {}+    for path in sorted(scope, key=lambda p: (p.count("+"), p)):+        occurrence = found[path]+        parent_path = path.rsplit("+", 1)[0] if "+" in path else None+        comp = concrete(occurrence.component)+        comp_id = identity(comp)+        instance_id = digest([SESSION_ID, value["documentId"], path, comp_id])+        parent = next((r for r in rows if r["path"] == parent_path), None)+        rows.append({"instanceId": instance_id, "path": path, "occurrence": ref(occurrence, value),+                     "parentInstanceId": parent["instanceId"] if parent else None, "componentId": comp_id,+                     "componentName": text(comp.name, 256), "homeMatrix": matrix(occurrence.transform2)})+        handles[instance_id] = occurrence+    return {"instances": rows, "rootBodyGroup": {}}, handles++ def held_back(snapshot, poses, handles):     """Instances Fusion did not put where the plan says (a joint, a pattern or a constraint held them):     the frames would show them standing still, so the caller is told which ones."""@@ -41,6 +81,17 @@ def held_back(snapshot, poses, handles):     return out  +def home_drift(before, after):+    """The largest difference, in mm (rotation terms at a 1 m lever arm), between two captures' home poses;+    None when an instance appeared or vanished. Exact equality is wrong here: reverting a big assembly+    leaves float noise far below any real tolerance."""+    a = {row["path"]: row["homeMatrix"] for row in before["instances"]}+    b = {row["path"]: row["homeMatrix"] for row in after["instances"]}+    if set(a) != set(b):+        return None+    return max([abs(x - y) * (1 if i in (3, 7, 11) else 1000) for path in a for i, (x, y) in enumerate(zip(a[path], b[path]))] or [0.0])++ def captions(steps, total, playback):     """Each step's title and instruction with its time range in the video (reversed for assemble)."""     out = []@@ -83,7 +134,8 @@ def exploded_view_render(app, args):     require(not snapshots.hasPendingSnapshot, "pending_snapshot",             "The design has an uncaptured move; capture or revert it first (rendering reverts every move it makes).")     flags = (document.isSaved, document.isModified)-    snapshot, handles = capture(design, value)+    scope = moved_scope(design, value, args["steps"])+    snapshot, handles = capture(design, value, scope)     steps = compile_steps(design, value, snapshot, handles, args["steps"])     viewport = app.activeViewport     original_camera = viewport.camera@@ -117,13 +169,15 @@ def exploded_view_render(app, args):         original_camera.isSmoothTransition = False         viewport.camera = original_camera         viewport.refresh()-    after, _ = capture(design, value)-    require(after == snapshot, "restore_failed", "Instances did not return to their home poses after rendering.")+    after, _ = capture(design, value, scope)+    drift = home_drift(snapshot, after)+    require(drift is not None and drift <= POSE_TOLERANCE_MM, "restore_failed",+            "Instances did not return to their home poses after rendering (off by %s mm)." % (round(drift, 4) if drift is not None else "a missing instance"))     require((document.isSaved, document.isModified) == flags and not snapshots.hasPendingSnapshot, "restore_failed",             "Document state changed during rendering.")     return {"target": value, "folder": str(folder), "frames": written, "fps": fps, "size": [width, height],             "durationSeconds": round(len(times) / fps, 3), "playback": playback, "captions": captions(steps, total, playback),-            "pattern": "frame_%05d.png", "restored": True, "heldBack": held,+            "pattern": "frame_%05d.png", "restored": True, "restoredWithinMm": round(drift, 6), "heldBack": held,             "encode": "ffmpeg -framerate %d -i frame_%%05d.png -c:v libx264 -pix_fmt yuv420p -crf 18 exploded.mp4" % fps}  
addin/AdomBridge/commands/mechanical/schema.json+138
@@ -7515,5 +7515,143 @@       "target",       "filename"     ]+  },+  "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.",+    "properties": {+      "library": {+        "type": "string",+        "enum": [+          "milling",+          "hole"+        ]+      },+      "type": {+        "type": "string"+      },+      "limit": {+        "type": "integer",+        "minimum": 1,+        "maximum": 500+      }+    },+    "required": [+      "op"+    ]+  },+  "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.",+    "properties": {+      "operationId": {+        "type": "string"+      },+      "name": {+        "type": "string"+      },+      "bodies": {+        "type": "array",+        "minItems": 1,+        "maxItems": 32,+        "items": {+          "type": "object"+        }+      },+      "operations": {+        "type": "array",+        "minItems": 1,+        "maxItems": 32,+        "items": {+          "type": "object",+          "properties": {+            "strategy": {+              "type": "string",+              "enum": [+                "face",+                "adaptive",+                "contour",+                "parallel",+                "scallop",+                "pocket_clearing",+                "drill"+              ]+            },+            "name": {+              "type": "string"+            },+            "tool": {+              "type": "object",+              "properties": {+                "type": {+                  "type": "string",+                  "description": "Tool type as Fusion names it: flat end mill, ball end mill, bull nose end mill, drill, chamfer mill, ..."+                },+                "diameterMm": {+                  "type": "number",+                  "exclusiveMinimum": 0,+                  "maximum": 200+                }+              },+              "additionalProperties": false+            },+            "parameters": {+              "type": "object"+            },+            "holeFaces": {+              "type": "array",+              "maxItems": 512,+              "items": {+                "type": "object"+              }+            }+          },+          "required": [+            "strategy"+          ],+          "additionalProperties": false+        }+      },+      "post": {+        "type": "object",+        "properties": {+          "machine": {+            "type": "string"+          },+          "folder": {+            "type": "string"+          },+          "program": {+            "type": "string"+          }+        },+        "required": [+          "folder"+        ],+        "additionalProperties": false+      },+      "timeoutSeconds": {+        "type": "number",+        "minimum": 10,+        "maximum": 3600+      }+    },+    "required": [+      "op",+      "sessionId",+      "operationId",+      "target",+      "bodies",+      "operations"+    ]+  },+  "cam_inspect": {+    "type": "object",+    "description": "Read the active document's CAM: setups, their operations' toolpath state, warnings and errors, and NC programs.",+    "properties": {},+    "required": [+      "op",+      "target"+    ]   } }
docs/MECHANICAL.md+20
@@ -277,6 +277,26 @@ Create/insert transforms use parent-component coordinates. Creation, insertion a  `efficiency_report` reads what makes a design slow to recompute, open and edit: timeline make-up and health, Position snapshots, Move features, size, repeated definitions made by Paste New, instance-weighted faces, imported history and wires in a parametric design, big sketches, pattern compute options, modeled threads, meshes and empty components. It returns `metrics` and `findings` (rule, severity, measured, limit, fix), worst first. It never changes the model and needs no revision digest; it is journaled like `bom_export`, so a long report on a big design is followed with `fusion_mech_operation_status`. Passes past `budgetSeconds` (default 120) are skipped and listed with `complete:false`; `computeTiming:true` also times a full `computeAll`. The rules and the benchmarks behind them are in `skills/fusion-design-efficiency/SKILL.md`. +## 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_job` makes a milling job in one journaled call. It takes the `bodies` to machine and `operations` in order, each a `strategy`:+- `face`;+- `adaptive`, Fusion's 3D adaptive roughing;+- `contour`, 3D contour finishing;+- `parallel` and `scallop`;+- `pocket_clearing`;+- `drill`.++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 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`.++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.++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).+ ## McMaster and purchased CAD  `mcmaster_discover` reports availability of Fusion's native Insert McMaster-Carr command. `mcmaster_open` launches its interactive supplier picker in the targeted Hybrid design. Its recorded result is a launch request, not proof that a part was inserted. Inspect again after insertion or cancellation. This operation does not place orders.
rust/core/tests/golden/mech_capabilities.json+139
@@ -7530,6 +7530,144 @@      "target",      "filename"     ]+   },+   "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.",+    "properties": {+     "library": {+      "type": "string",+      "enum": [+       "milling",+       "hole"+      ]+     },+     "type": {+      "type": "string"+     },+     "limit": {+      "type": "integer",+      "minimum": 1,+      "maximum": 500+     }+    },+    "required": [+     "op"+    ]+   },+   "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.",+    "properties": {+     "operationId": {+      "type": "string"+     },+     "name": {+      "type": "string"+     },+     "bodies": {+      "type": "array",+      "minItems": 1,+      "maxItems": 32,+      "items": {+       "type": "object"+      }+     },+     "operations": {+      "type": "array",+      "minItems": 1,+      "maxItems": 32,+      "items": {+       "type": "object",+       "properties": {+        "strategy": {+         "type": "string",+         "enum": [+          "face",+          "adaptive",+          "contour",+          "parallel",+          "scallop",+          "pocket_clearing",+          "drill"+         ]+        },+        "name": {+         "type": "string"+        },+        "tool": {+         "type": "object",+         "properties": {+          "type": {+           "type": "string",+           "description": "Tool type as Fusion names it: flat end mill, ball end mill, bull nose end mill, drill, chamfer mill, ..."+          },+          "diameterMm": {+           "type": "number",+           "exclusiveMinimum": 0,+           "maximum": 200+          }+         },+         "additionalProperties": false+        },+        "parameters": {+         "type": "object"+        },+        "holeFaces": {+         "type": "array",+         "maxItems": 512,+         "items": {+          "type": "object"+         }+        }+       },+       "required": [+        "strategy"+       ],+       "additionalProperties": false+      }+     },+     "post": {+      "type": "object",+      "properties": {+       "machine": {+        "type": "string"+       },+       "folder": {+        "type": "string"+       },+       "program": {+        "type": "string"+       }+      },+      "required": [+       "folder"+      ],+      "additionalProperties": false+     },+     "timeoutSeconds": {+      "type": "number",+      "minimum": 10,+      "maximum": 3600+     }+    },+    "required": [+     "op",+     "sessionId",+     "operationId",+     "target",+     "bodies",+     "operations"+    ]+   },+   "cam_inspect": {+    "type": "object",+    "description": "Read the active document's CAM: setups, their operations' toolpath state, warnings and errors, and NC programs.",+    "properties": {},+    "required": [+     "op",+     "target"+    ]    }   },   "transactions": {@@ -7557,6 +7695,7 @@  "writes": [   "bom_export",   "cad_import",+  "cam_job",   "component_create",   "component_metadata",   "construction_axis",
rust/core/tests/golden/mech_validation.json
@@ -1 +1 @@