# For agents (/docs/agents) fusionctl was written for coding agents as much as for people. The commands print JSON when they are not on a terminal, and every check returns numbers an agent can compare against a spec. It needs an agent that can run shell commands. For a chat app without a terminal, Autodesk's own Fusion MCP Server is the better fit; [fusionctl and MCP](/docs/mcp) compares the two. ## For humans [#for-humans] Read [Installation](/docs/installation) and the [Quickstart](/docs/quickstart) first. [Scripting in Fusion](/docs/scripting) and [Verifying a part](/docs/verifying) are the two pages an agent's rules are built from. Every page has **Copy Markdown** and **Open** buttons at the top if you want to paste it into a chat. ## For agents [#for-agents] The same docs as plain text, built with the site: | URL | What | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](https://fusionctl.lucaspiera.com/llms.txt) | index of every page with a one-line summary | | [`/llms-full.txt`](https://fusionctl.lucaspiera.com/llms-full.txt) | all pages in one file | | `/llms.mdx/docs//content.md` | one page as Markdown, e.g. [`/llms.mdx/docs/scripting/content.md`](https://fusionctl.lucaspiera.com/llms.mdx/docs/scripting/content.md) | | [`AGENTS.md`](https://github.com/pieralukasz/fusionctl/blob/main/AGENTS.md) | the rules below, in the repository ([raw](https://raw.githubusercontent.com/pieralukasz/fusionctl/main/AGENTS.md)) | Put a line like this in your project's `CLAUDE.md` or `AGENTS.md`: ```txt Before driving Fusion with fusionctl, read https://github.com/pieralukasz/fusionctl/blob/main/AGENTS.md and https://fusionctl.lucaspiera.com/llms-full.txt ``` ## A recorded session [#a-recorded-session] The replay on the [home page](/) is a real session, recorded on 18 October 2026 on Fusion 2705.1.30 (macOS). Claude, working in a terminal, was given one request, "M8 x 20 hex bolt, modelled thread, ready to print.", and ran these commands. All of them exited `0`. ```sh fusionctl ping fusionctl new fusionctl run examples/bolt/01-params.py --atomic # d, length, head_af, head_h, tip fusionctl run examples/bolt/02-head.py --atomic # hex head fusionctl bodies fusionctl run examples/bolt/03-shank.py --atomic # shank fusionctl bodies fusionctl run examples/bolt/04-tip.py --atomic # tip chamfer fusionctl run examples/bolt/05-thread.py --atomic # modelled thread fusionctl timeline fusionctl bodies fusionctl screenshot bolt-iso.png --view iso fusionctl export bolt.3mf fusionctl export bolt.step ``` What it read back along the way: | After | `bodies` said | Checked by hand | | ---------------------- | ------------------------------------------ | ------------------------------------------------------------------- | | head | 804.97 mm³, 13 x 15.01 x 5.5 mm | (√3/2) · 13² · 5.5 = 804.97 | | shank | 1810.28 mm³, 13 x 15.01 x 25.5 mm | + π · 4² · 20 = 1005.31 | | tip chamfer and thread | 1605.1 mm³, 13 x 15.01 x 25.5 mm, 15 faces | less than before: the chamfer and the thread groove remove material | `05-thread.py` let Fusion pick the thread with `recommendThreadData`: M8x1.25, class 6g. The timeline ended with seven items, all `ok`. Afterwards `fusionctl params --set length=30mm head_af=10mm` regenerated the bolt, thread included, with every item still `ok` (not captured on the home page). The scripts are in [`examples/bolt`](https://github.com/pieralukasz/fusionctl/tree/main/examples/bolt). The exports: [bolt.step](https://fusionctl.lucaspiera.com/files/bolt.step) and [bolt.3mf](https://fusionctl.lucaspiera.com/files/bolt.3mf). ### A second session: a wristwatch [#a-second-session-a-wristwatch] The home page also replays a larger session from the same day: an agent built an analog wristwatch with 32 commands, all exiting `0`, and 15 scripts in [`examples/watch`](https://github.com/pieralukasz/fusionctl/tree/main/examples/watch). It has 11 components and 14 bodies, 8 rigid joints and 3 revolute joints for the hands, set to 10:10:30. After each step it read `bodies` and `timeline`; at the end `fusionctl interference` found no interference between the 14 bodies, also after `params --set case_d=36mm` regenerated the whole watch. Then it turned the hands through one hour on their joints. [Assemblies and joints](/docs/assemblies) walks through the code. The exports: [watch.step](https://fusionctl.lucaspiera.com/files/watch.step) and [watch.3mf](https://fusionctl.lucaspiera.com/files/watch.3mf). ## The rules in AGENTS.md [#the-rules-in-agentsmd] A summary. The [file itself](https://github.com/pieralukasz/fusionctl/blob/main/AGENTS.md) is the reference; every rule in it was checked on a live Fusion (build 2705.1.30, macOS, October 2026). ### Start of a session [#start-of-a-session] 1. `fusionctl doctor`; fix any failing check before modelling. 2. `fusionctl ping` and read `orientation`: Z-up means the bed is XY, Y-up means the floor is XZ. Do not assume either. 3. `fusionctl new` for a fresh parametric design in millimetres. ### Units and parameters [#units-and-parameters] * The API works in centimetres and radians. `mm(5)` returns `0.5`. Millimetre numbers in `Point3D` make the part ten times too large without an error. * Use `VS("12 mm")` or `VS("width / 2")` for feature sizes, and create user parameters first so `fusionctl params --set` regenerates the model. ### Sketch planes [#sketch-planes] | Plane | sketch +X | sketch +Y | extrude (+) | | --------------------- | ------------ | ------------ | ----------- | | `xYConstructionPlane` | world +X | world +Y | +Z | | `xZConstructionPlane` | world +X | **world −Z** | +Y | | `yZConstructionPlane` | **world −Z** | world +Y | +X | ### Pitfalls [#pitfalls] * Two sketch points with the same X cannot both get a horizontal dimension from the origin (`VCS_SKETCH_OVER_CONSTRAINTS`). Dimension one fully, the other only in Y, then `addVerticalPoints`. * Holes drill against the sketch normal; from the XY plane at the bed use `NegativeExtentDirection` to drill up. * A bare sketch point does not follow parameter changes; dimension it to the origin with an expression. * New occurrences are grounded to their parent; set `isGroundToParent = False` before `transform2`, then take a snapshot if one is pending. * The 3MF export method is `createC3MFExportOptions`. * Never open dialogs from a script: a modal dialog blocks the bridge. * Check class and method names with `fusionctl apidoc` instead of guessing from older samples. ### Work in small, checked steps [#work-in-small-checked-steps] * One feature or a few per `run`, with `--atomic`. * After each step: `timeline` (all `ok`) and `bodies` (count, volume, bounding box change as expected). * No exception is not proof. Measure, run `interference` for assemblies, and look at `screenshot --view iso`, `front` and `top` before saying it is done. * Fix a failing native feature instead of replacing it with manual cuts. * Export STEP as the source of truth, 3MF or STL for the slicer, and print a test piece of any critical fit. ### Output and exit codes [#output-and-exit-codes] JSON when stdout is not a terminal or with `--json`. `0` success, `1` Fusion or the script failed (traceback on stderr, plus what `--atomic` rolled back), `2` bad arguments. # Assemblies and joints (/docs/assemblies) fusionctl has no assembly commands of its own. `fusionctl run` gives a script the whole Fusion API, so components and joints are made in Python, and `bodies`, `timeline` and `interference` read the assembly back. Everything on this page comes from one session, recorded on 18 October 2026 on Fusion 2705.1.30 (macOS): a coding agent built an analog wristwatch in 15 scripts, [`examples/watch`](https://github.com/pieralukasz/fusionctl/tree/main/examples/watch). The [home page](/) replays it. | What | In the watch | | ------------------------ | --------------------------------------------------------------------------------------------------------------------- | | Components | 11, one per part, with 14 solid bodies | | Joints | 8 rigid (Caseback, Crown, Dial, Arbor, Glass, Bezel, Straps, Spring bars), 3 revolute (Hour, Minute and Second pivot) | | Size | 43.6 x 78.4 x 12.7 mm | | Timeline | every item `ok` | | `fusionctl interference` | no interference between the 14 bodies | ## Components [#components] Give every part its own component. A body in the root is fine for a single part; joints connect occurrences of components, and `bodies` then lists each part under its own occurrence. The case is the first one: ```py title="examples/watch/02-case.py" occ = root.occurrences.addNewComponent(adsk.core.Matrix3D.create()) case = occ.component case.name = "Case" ``` Everything after that is built inside `case`: its sketches on `case.xYConstructionPlane`, its features from `case.features`. Later scripts find the component again by name with `design.allComponents.itemByName("Case")`. Each part was built in place, at its final position, with its component origin at the dial centre. That keeps the joints simple: every joint connects two component origins. ```txt title="fusionctl bodies, after the hands (13-bodies)" body where solid volume mm³ size mm min mm faces Case Case:1 yes 9461.05 40 x 54 x 10 -20 x -27 x 0 41 Crown Crown:1 yes 53.53 4.6 x 5 x 4.95 19 x -2.5 x 2.52 41 Caseback Caseback:1 yes 1512.81 36 x 36 x 1.5 -18 x -18 x -1.5 4 Dial Dial:1 yes 438.02 33 x 33 x 0.8 -16.5 x -16.5 x 6 63 Arbor Arbor:1 yes 0.7 0.8 x 0.8 x 1.4 -0.4 x -0.4 x 6.5 3 Hour hand Hour hand:1 yes 3.7 2.6 x 10.54 x 0.3 -1.3 x -1.3 x 7 8 Minute hand Minute hand:1 yes 3.58 2.2 x 14.3 x 0.3 -1.1 x -1.1 x 7.5 8 Second hand Second hand:1 yes 2.46 1.6 x 18.19 x 0.3 -0.8 x -4 x 8 11 ``` `where` is the occurrence path. The sizes and positions are in world millimetres, so a part inside a component is checked where it really is. ## Rigid and revolute joints [#rigid-and-revolute-joints] [`13-joints.py`](https://github.com/pieralukasz/fusionctl/blob/main/examples/watch/13-joints.py) grounds the case, fixes eight parts to it with rigid joints and puts the three hands on revolute joints about Z: ```py title="examples/watch/13-joints.py" J = adsk.fusion.JointGeometry occ = {o.component.name: o for o in root.occurrences} def origin(name): o = occ[name] return J.createByPoint(o.component.originConstructionPoint.createForAssemblyContext(o)) occ["Case"].isGrounded = True joints = {} for part in ("Caseback", "Crown", "Dial", "Arbor", "Glass", "Bezel", "Straps", "Spring bars"): ji = root.joints.createInput(origin(part), origin("Case")) ji.setAsRigidJointMotion() j = root.joints.add(ji) j.name = part + " fixed" for hand in ("Hour hand", "Minute hand", "Second hand"): ji = root.joints.createInput(origin(hand), origin("Dial")) ji.setAsRevoluteJointMotion(adsk.fusion.JointDirections.ZAxisJointDirection) j = root.joints.add(ji) j.name = hand.split()[0] + " pivot" result = {j.name: j.jointMotion.objectType.split("::")[-1] for j in root.joints} ``` * `JointGeometry.createByPoint` on `originConstructionPoint.createForAssemblyContext(occurrence)`: the component origin, seen from the root, where joints live. * `root.joints.createInput(moving, fixed)`: the first geometry moves to the second. * `setAsRigidJointMotion()` locks a part; `setAsRevoluteJointMotion(ZAxisJointDirection)` leaves one rotation about Z. ```txt title="fusionctl run examples/watch/13-joints.py --atomic" { "Caseback fixed": "RigidJointMotion", "Crown fixed": "RigidJointMotion", "Dial fixed": "RigidJointMotion", "Arbor fixed": "RigidJointMotion", "Glass fixed": "RigidJointMotion", "Bezel fixed": "RigidJointMotion", "Straps fixed": "RigidJointMotion", "Spring bars fixed": "RigidJointMotion", "Hour pivot": "RevoluteJointMotion", "Minute pivot": "RevoluteJointMotion", "Second pivot": "RevoluteJointMotion" } ``` The joints are timeline items like any feature, and grounding the case adds a `Pin1` item. The end of the timeline after this step (12 of its 102 rows): ```txt title="fusionctl timeline (last rows)" # feature type health message 90 Pin1 Occurrence ok 91 Caseback fixed Joint ok 92 Crown fixed Joint ok 93 Dial fixed Joint ok 94 Arbor fixed Joint ok 95 Glass fixed Joint ok 96 Bezel fixed Joint ok 97 Straps fixed Joint ok 98 Spring bars fixed Joint ok 99 Hour pivot Joint ok 100 Minute pivot Joint ok 101 Second pivot Joint ok ``` ## Driving a joint [#driving-a-joint] A revolute joint turns its part through `jointMotion.rotationValue`, in radians. [`14-time.py`](https://github.com/pieralukasz/fusionctl/blob/main/examples/watch/14-time.py) sets 10:10:30 and reads each hand tip back in world space: ```py title="examples/watch/14-time.py" import math H, M, S = 10, 10, 30 angles = { # clockwise degrees from 12 o'clock "Hour": (H % 12 + M / 60 + S / 3600) * 30, "Minute": (M + S / 60) * 6, "Second": S * 6, } occ = {o.component.name: o for o in root.occurrences} length = {"Hour": "hour_l", "Minute": "min_l", "Second": "sec_l"} out = {} for hand, deg in angles.items(): j = root.joints.itemByName(hand + " pivot") j.jointMotion.rotationValue = -math.radians(deg) adsk.doEvents() o = occ[hand + " hand"] tip = adsk.core.Point3D.create(0, design.userParameters.itemByName(length[hand]).value, 0) tip.transformBy(o.transform2) out[hand] = {"set_deg": round(deg, 2), "joint_deg": round(-math.degrees(j.jointMotion.rotationValue), 2), "tip_clock_deg": round(math.degrees(math.atan2(tip.x, tip.y)) % 360, 2), "tip_xy_mm": [round(tip.x * 10, 2), round(tip.y * 10, 2)]} ``` Clockwise seen from above (+Z) is a negative rotation about Z, hence `-math.radians(deg)`. `o.transform2` is where the joint moved the occurrence, so the tip read through it shows whether the hand points where the clock says: ```txt title="fusionctl run examples/watch/14-time.py --atomic" { "Hour": { "set_deg": 305.25, "joint_deg": 305.3, "tip_clock_deg": 305.3, "tip_xy_mm": [ -7.54, 5.34 ] }, "Minute": { "set_deg": 63, "joint_deg": 63, "tip_clock_deg": 63, "tip_xy_mm": [ 11.76, 5.99 ] }, "Second": { "set_deg": 180, "joint_deg": 180, "tip_clock_deg": 180, "tip_xy_mm": [ 0, -14.19 ] }, "clearance_mm": { "Dial / Hour hand": 0.5, "Hour hand / Minute hand": 0.2, "Minute hand / Second hand": 0.2, "Second hand / Glass": 0.9, "Arbor / Hour hand": 0.1, "Arbor / Minute hand": 0.1, "Arbor / Second hand": 0.1 } } ``` `joint_deg` and `tip_clock_deg` agree: hour 305.3°, minute 63°, second 180°. The hour hand was set to 305.25° and reads back 305.3°, see the first pitfall below. [`15-turn.py`](https://github.com/pieralukasz/fusionctl/blob/main/examples/watch/15-turn.py) then turns the hands through one hour in 24 steps, saving a frame from two fixed cameras at each step. Before it captures anything it hides the joint glyphs and construction geometry: ```py title="examples/watch/15-turn.py" root.isJointsFolderLightBulbOn = False # hide joint glyphs and construction axes/planes for comp in design.allComponents: comp.isConstructionFolderLightBulbOn = False app.userInterface.activeSelections.clear() vp = app.activeViewport joints = {h: root.joints.itemByName(h + " pivot") for h in ("Hour", "Minute", "Second")} start = {"Hour": (10 + 10.5 / 60) * 30, "Minute": 10.5 * 6, "Second": 180.0} step = {"Hour": 15 / 12, "Minute": 15.0, "Second": 60.0} log = [] for i in range(FRAMES + 1): clock = {h: (start[h] + i * step[h]) % 360 for h in joints} for h, j in joints.items(): j.jointMotion.rotationValue = -math.radians(clock[h]) adsk.doEvents() ``` ## Interference and clearance [#interference-and-clearance] `fusionctl interference` checks every pair of visible solid bodies: ```txt title="fusionctl interference" No interference between 14 solid bodies. ``` No overlap does not prove the parts fit, so `14-time.py` also measures the air between them at this pose with `measureMinimumDistance`: ```py title="examples/watch/14-time.py" # clearances at this pose, measured on the assembly-context bodies (mm) def body(comp): return occ[comp].bRepBodies.item(0) gaps = {} for a, b in (("Dial", "Hour hand"), ("Hour hand", "Minute hand"), ("Minute hand", "Second hand"), ("Second hand", "Glass"), ("Arbor", "Hour hand"), ("Arbor", "Minute hand"), ("Arbor", "Second hand")): gaps[f"{a} / {b}"] = round(app.measureManager.measureMinimumDistance(body(a), body(b)).value * 10, 3) out["clearance_mm"] = gaps ``` At 10:10:30: dial to hour hand 0.5 mm, hour to minute hand 0.2 mm, minute to second hand 0.2 mm, second hand to glass 0.9 mm, arbor to each hub 0.1 mm. `09-hands.py` places the hand layers with the user parameters `hand_t` and `hand_gap`. ## Parameters through the assembly [#parameters-through-the-assembly] The sizes are 22 user parameters, created by [`01-params.py`](https://github.com/pieralukasz/fusionctl/blob/main/examples/watch/01-params.py). One change regenerates every component: ```txt title="fusionctl params --set case_d=36mm" case_d 36 mm ``` ```txt title="fusionctl bodies" body where solid volume mm³ size mm min mm faces Case Case:1 yes 7910.61 36 x 50 x 10 -18 x -25 x 0 41 Crown Crown:1 yes 53.53 4.6 x 5 x 4.95 17 x -2.5 x 2.52 41 Caseback Caseback:1 yes 1193.94 32 x 32 x 1.5 -16 x -16 x -1.5 4 Dial Dial:1 yes 340.63 29 x 29 x 0.8 -14.5 x -14.5 x 6 63 Arbor Arbor:1 yes 0.7 0.8 x 0.8 x 1.4 -0.4 x -0.4 x 6.5 3 Hour hand Hour hand:1 yes 3.32 2.6 x 9.42 x 0.3 -1.3 x -1.3 x 7 8 Minute hand Minute hand:1 yes 3.19 2.2 x 12.7 x 0.3 -1.1 x -1.1 x 7.5 8 Second hand Second hand:1 yes 2.26 1.6 x 16.47 x 0.3 -0.8 x -4 x 8 11 Glass Glass:1 yes 557.97 29.8 x 29.8 x 0.8 -14.9 x -14.9 x 9.2 3 Bezel Bezel:1 yes 510.41 36 x 36 x 1.2 -18 x -18 x 10 6 Strap 12 Straps:1 yes 888.66 19.4 x 16 x 3 -9.7 x 21.2 x 3.5 9 Strap 6 Straps:1 yes 888.66 19.4 x 16 x 3 -9.7 x -37.2 x 3.5 9 Spring bar 12 Spring bars:1 yes 19.01 24.2 x 1 x 1 -12.1 x 22.5 x 4.5 3 Spring bar 6 Spring bars:1 yes 19.01 24.2 x 1 x 1 -12.1 x -23.5 x 4.5 3 ``` ```txt title="fusionctl interference" No interference between 14 solid bodies. ``` The recompute put the driven joints back to 0°, which is why the hands point at 12. After `params --set case_d=40mm` the session ran `14-time.py` again to set 10:10:30. ## Pitfalls [#pitfalls] Each of these was met while building the watch on Fusion 2705.1.30. * **`rotationValue` snaps to the angular display precision.** 12.34° reads back as 12.3°, and 305.25° as 305.3°. Compare read-back angles with that tolerance. * **Any recompute resets driven joints to 0°.** `params --set` is a recompute, so set the pose again after a parameter edit (re-run the script that drives the joints). * **Clockwise seen from +Z is a negative `rotationValue`.** A clock face looked at from above needs `-math.radians(deg)`. * **Joint and axis glyphs show up in screenshots.** `root.isJointsFolderLightBulbOn = False` hides the joints, and `comp.isConstructionFolderLightBulbOn = False` for each component hides construction axes and planes. * **A sketch and a feature with the same name make Fusion rename the feature,** to "Lug (1)". Give sketches their own names ("Lug outline") and keep references to the feature objects instead of looking them up by name, as `03-lugs.py` does for its mirror: ```py title="examples/watch/03-lugs.py" feats = adsk.core.ObjectCollection.create() feats.add(lug); feats.add(lug_fillet) m = case.features.mirrorFeatures mirror9 = m.add(m.createInput(feats, case.yZConstructionPlane)) mirror9.name = "Lug mirror 9" feats.add(mirror9) m.add(m.createInput(feats, case.xZConstructionPlane)).name = "Lug mirror 6" ``` * **Fully constrain every sketch a parameter drives.** The first hand outline had only its tip dimensioned; at `case_d=36mm` the solver dragged the free corners and the hour hub slid into the arbor. `09-hands.py` now fixes the base corners, dimensions the tip and the shoulders, and checks it: `assert bs.isFullyConstrained, name`. ## Files [#files] * The scripts: [`examples/watch`](https://github.com/pieralukasz/fusionctl/tree/main/examples/watch), with a README listing the commands in order. * The exports from the session: [watch.step](https://fusionctl.lucaspiera.com/files/watch.step) and [watch.3mf](https://fusionctl.lucaspiera.com/files/watch.3mf). * [Verifying a part](/docs/verifying) has the checks for single parts; [Scripting in Fusion](/docs/scripting) has the units and sketch-plane rules the scripts rely on. # Commands (/docs/commands) ```txt title="fusionctl --help" Usage: fusionctl [options] [--json] ``` Every command accepts `--json` and `--help`. Output is a table or plain text on a terminal, and JSON when stdout is not a terminal (piped, redirected, called from an agent) or with `--json`. | Exit code | Meaning | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success | | `1` | Fusion or the script failed; the script's traceback is on stderr, plus what `--atomic` rolled back. An error the add-in raises on purpose is one line, e.g. `error: undo: only 3 timeline item(s) exist` | | `2` | bad arguments; the usage line is printed | | Group | Commands | | ------------------------------------------- | ---------------------------------------------- | | [Session](#session) | `ping`, `doctor`, `install`, `reload` | | [Designs and scripts](#designs-and-scripts) | `new`, `exec`, `run`, `reset`, `undo`, `save` | | [Reading the model](#reading-the-model) | `timeline`, `bodies`, `params`, `interference` | | [Pictures and files](#pictures-and-files) | `screenshot`, `export` | | [API lookup](#api-lookup) | `apidoc` | ## Session [#session] ### ping [#ping] ```txt Usage: fusionctl ping ``` Fusion version, signed-in user, active document, units and model orientation (Y-up / Z-up). Also reports the version of the running add-in. ```txt title="fusionctl ping" fusion 2705.1.30 document Untitled design parametric units mm orientation Z-up bridge 0.1.0 ``` ### doctor [#doctor] ```txt Usage: fusionctl doctor ``` Check the add-in install, the token and the live bridge, and say what to fix. Exits with `1` if any check fails. The transcript below was recorded with version 0.1.0; the current release is 0.2.0. The sign-in line is left out here. ```txt title="fusionctl doctor" ok add-in installed v0.1.0 ok bridge reachable Fusion 2705.1.30 ok running add-in version v0.1.0 ok orientation Z-up (affects which plane is the floor) ``` ### install [#install] ```txt Usage: fusionctl install [--dir PATH] ``` Copy the FusionBridge add-in into Fusion's AddIns folder (it starts with Fusion). The add-in is embedded in the CLI, so no checkout is needed. `--dir` installs into another folder (a `FusionBridge` folder is created inside it). ### reload [#reload] ```txt Usage: fusionctl reload ``` Reload `bridge_ops.py` inside Fusion after editing the add-in (no restart). Changes to `FusionBridge.py` itself need an add-in restart. ## Designs and scripts [#designs-and-scripts] ### new [#new] ```txt Usage: fusionctl new ``` New parametric design in millimetres; clears the persistent exec globals. ### exec [#exec] ```txt Usage: fusionctl exec 'PYTHON' [--atomic] ``` Run Python inside Fusion. Globals: `app ui design root adsk mm P V VS`. Set `result = ...` to return JSON. What the script prints comes first, then `result`. See [Scripting in Fusion](/docs/scripting). ```sh fusionctl exec 'result = design.userParameters.count' ``` ### run [#run] ```txt Usage: fusionctl run FILE.py [--atomic] ``` Like exec, from a file. `--atomic` deletes the timeline items the script added if it fails. Tracebacks show the lines of your file. ### reset [#reset] ```txt Usage: fusionctl reset ``` Forget the variables kept between exec/run calls. Variables a script defines stay available to the next `exec` or `run` until `reset` or `new`. ### undo [#undo] ```txt Usage: fusionctl undo [N] ``` Delete the last N timeline items (default 1) and list what was removed. Parameter edits are not undone. It works on the timeline itself: Fusion's own Undo command does nothing for edits made through the API. It needs a parametric design with the timeline marker at the end, and refuses to remove more items than exist: ```txt title="fusionctl undo 2" Removed Shank, Shank circle. 3 timeline item(s) left. ``` ```txt error: undo: only 3 timeline item(s) exist ``` To go back after `params --set`, set the old value again. ### save [#save] ```txt Usage: fusionctl save [--name NAME] [MESSAGE] ``` Save the document (new documents need `--name`; they go to the active project). `MESSAGE` becomes the version description. ## Reading the model [#reading-the-model] ### timeline [#timeline] ```txt Usage: fusionctl timeline ``` Every timeline feature with its health and message. Health is one of `ok`, `warning`, `error`, `suppressed`, `rolled back` or `unknown`. Suppressed features are marked. Direct-modelling designs have no timeline. ```txt title="fusionctl timeline" # feature type health message 0 L profile Sketch ok 1 Bracket body ExtrudeFeature ok 2 Hole points Sketch ok 3 Base holes HoleFeature ok 4 Wall hole HoleFeature ok 5 Bend fillet FilletFeature ok 6 Bed chamfer ChamferFeature ok ``` ### bodies [#bodies] ```txt Usage: fusionctl bodies ``` Bodies in world coordinates: volume (mm³), bounding box (mm), face count. Bodies inside occurrences are listed with the occurrence path in `where`, at their real position. The JSON output also has the surface area, edge and shell counts and visibility. ```txt title="fusionctl bodies" body where solid volume mm³ size mm min mm faces Bracket root yes 6168.75 40 x 24 x 30 0 x -12 x 0 16 ``` ### params [#params] ```txt Usage: fusionctl params [--all] | params --set NAME=EXPR [NAME=EXPR ...] ``` List user parameters (`--all`: model parameters too), or set/create them with `--set`. A name that does not exist is created as a user parameter in millimetres. The model regenerates after a change. From version 0.2.0 the table's columns are `name`, `expression`, `value` and `kind`, where `value` is the formatted value (e.g. `8.00 mm`), and the JSON has a `display` field with the same text. The table below was recorded with 0.1.0, which printed the unit instead: ```txt title="fusionctl params (0.1.0)" name expression unit kind length 40 mm mm user height 30 mm mm user width 24 mm mm user thick 4 mm mm user hole_d 4.5 mm mm user bend_r 3 mm mm user bed_chamfer 0.6 mm mm user ``` ```sh fusionctl params --set length=55mm hole_d=5.5mm ``` ### interference [#interference] ```txt Usage: fusionctl interference ``` Pairwise interference of all visible solid bodies. Zero overlap does not prove a fit: measure gaps too. Coincident faces do not count as interference. ```txt title="fusionctl interference" No interference between 1 solid body. ``` ## Pictures and files [#pictures-and-files] ### screenshot [#screenshot] ```txt Usage: fusionctl screenshot OUT.png [--view iso|iso-bottom|front|back|top|bottom|left|right] [--size 1280x960] ``` Save the viewport as PNG, optionally from a named view (fits the model first). Without `--view` the current camera is kept and the model is fitted. ### export [#export] ```txt Usage: fusionctl export OUT.step|.stl|.3mf|.f3d ``` Export the whole design; the format follows the extension. `.stp` also works for STEP. STL is written with high mesh refinement. Prints the path and the size; an empty file counts as a failure. ## API lookup [#api-lookup] ### apidoc [#apidoc] ```txt Usage: fusionctl apidoc TERM [--kind class|member|text|all] [--limit N] ``` Search the live adsk API of this Fusion build, e.g. `ExtrudeFeatures.addSimple`. Narrow a search with `Class.member` or `fusion.Class`; `--kind text` searches the docstrings. The search is adapted from Autodesk's [FusionMCPSample](https://github.com/AutodeskFusion360/FusionMCPSample) (MIT). ```txt title="fusionctl apidoc ExtrudeFeatures.addSimple" adsk.fusion.ExtrudeFeatures.addSimple(profile: Base, distance: ValueInput, operation: FeatureOperations) -> adsk.fusion.ExtrudeFeature Creates a basic extrusion that goes from the profile plane the specified distance. profile : The profile argument can be a single Profile, a single planar face, a single SketchText object, or an ObjectCollection consisting of multiple profiles, planar faces, and sketch texts. When an ObjectCollection is used all of the profiles, faces, and sketch texts must be co-planar. This method can only be used to create solid extrusions. To create a surface extrusion you need to use the add method. distance : ValueInput object that defines the extrude distance. A positive value extrudes in the positive direction of the sketch plane and negative value is in the opposite direction. operation : The feature operation to perform. Returns the newly created ExtrudeFeature or null if the creation failed. ``` # How it works & security (/docs/how-it-works) ## The bridge [#the-bridge] Fusion's Python API only exists inside the Fusion process, and only on its main thread. `fusionctl install` adds a small add-in, **FusionBridge**, that starts with Fusion. ```txt fusionctl ──HTTP + token──▶ FusionBridge (127.0.0.1) ──custom event──▶ Fusion API on the main thread ``` 1. Each CLI command is one `POST` to `http://127.0.0.1:18765/` with a JSON body such as `{"op": "timeline"}` and the token in the `X-Fusion-Token` header. 2. The add-in's HTTP server runs on a worker thread. It puts the request in a queue and fires a Fusion custom event, the one API call that is safe off the main thread. 3. Fusion calls the event handler on its main thread. The handler drains the queue, runs each operation and stores the result. 4. The worker thread sends the result back as JSON: `{"ok": true, "data": …}` or `{"ok": false, "error": ""}`. The operations live in `bridge_ops.py`. `fusionctl reload` re-imports that file inside Fusion, so changes to the operations need no restart; changes to `FusionBridge.py` need an add-in restart. The worker waits up to 600 seconds for the main thread. While a modal dialog is open in Fusion the main thread does not process the event, so the request times out with `timeout (modal dialog open in Fusion?)`. ## The token [#the-token] * On its first start the add-in writes 48 random hex characters to `~/.config/fusionctl/token`. The folder is created with mode 700 and the file with mode 600, readable only by your user. * Every request must carry the token in `X-Fusion-Token`. A request without it, or with another value, gets `403 forbidden`. * The CLI reads the same file. If the two read different files (for example `FUSIONCTL_CONFIG_DIR` set for one and not the other), the CLI says the bridge rejected the token. ## Loopback only [#loopback-only] The server binds to `127.0.0.1`, so other machines on the network cannot connect. The port is `18765`, or `FUSIONCTL_PORT` if set in Fusion's environment. ## Browsers are refused [#browsers-are-refused] A web page in your browser can send requests to `127.0.0.1`. Browsers always add an `Origin` header to such cross-site requests, and the CLI never sends one, so the bridge refuses every request that has an `Origin` header, whatever token it carries. A page you visit cannot drive Fusion, even if it guessed the token. ## exec runs arbitrary Python [#exec-runs-arbitrary-python] `fusionctl exec` and `fusionctl run` execute Python inside Fusion, as your user, with access to your files, your network and your Autodesk account's documents. That is the point of the tool, and it means: * Read scripts from others before running them, as you would a shell script. * Anyone who can read your token file can do the same. Keep `~/.config/fusionctl` private. * A coding agent that can run `fusionctl` can run any Python on your machine through it. Give it the same trust you give its shell access. ## What leaves the Mac [#what-leaves-the-mac] Nothing, from fusionctl. The CLI talks only to `127.0.0.1`. Fusion itself talks to Autodesk's services as it always does, for example when you `save` a document to a cloud project. # Introduction (/docs) fusionctl is a command-line tool written in TypeScript for [Bun](https://bun.sh). It talks to a small add-in, **FusionBridge**, that runs inside Fusion and listens on `127.0.0.1:18765`. With it you can: * run Python scripts in the open design (`exec`, `run`), with `--atomic` to remove what a failing script added; * read the model back as numbers: the timeline with each feature's health, the bodies with volume and bounding box in millimetres, the parameters; * change parameters and let the model regenerate (`params --set`); * save viewport screenshots from named views, check interference, and export STEP, STL, 3MF or F3D. Output is a table on a terminal and JSON when piped or with `--json`, so the same commands work for a person and for a coding agent (Claude Code, Codex, OpenCode). ## Why a CLI [#why-a-cli] Fusion's Python API only exists inside the Fusion process. Scripts usually run from the Scripts and Add-Ins dialog, and their results are something you look at. fusionctl keeps Fusion open as the modelling kernel and moves everything else to the shell: scripts are files you edit and rerun, and every check prints numbers you can compare against a spec. That matters most for coding agents. An agent that writes a modelling script cannot see the viewport, so a script that ran without an exception is all it knows. With fusionctl it can run `timeline`, `bodies` and `interference`, and look at `screenshot` output, before it says the part is done. ## What you need [#what-you-need] * Autodesk Fusion, running and signed in. The free personal-use licence works: the API and add-ins are included. * Bun 1.2 or later. * macOS. Everything on this site was checked on Fusion 2705.1.30 on macOS in October 2026. The add-in path for Windows is in the code but has not been tested. ## Where to go next [#where-to-go-next] * [Installation](/docs/installation): three commands and a restart. * [Quickstart](/docs/quickstart): build the example bracket, read it back, change a parameter. * [Commands](/docs/commands): every command with its usage line. * [Assemblies and joints](/docs/assemblies): components, rigid and revolute joints, and the wristwatch an agent built. * [For agents](/docs/agents): the rules file and the plain-text versions of these docs. fusionctl is MIT-licensed and made by [Lucas Piera](https://lucaspiera.com). It is not affiliated with or endorsed by Autodesk. Autodesk and Fusion are trademarks of Autodesk, Inc. # Installation (/docs/installation) fusionctl is installed from GitHub with Bun. It is not published on npm. ### Install the CLI [#install-the-cli] ```sh bun install -g github:pieralukasz/fusionctl ``` This needs Bun 1.2 or later. The `fusionctl` command is then on your `PATH` (Bun's global bin directory). ### Install the add-in [#install-the-add-in] ```sh fusionctl install ``` The add-in files are embedded in the CLI. `install` writes them to Fusion's per-user add-in folder: | System | Folder | | ------------------ | ------------------------------------------------------------------------------------ | | macOS | `~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/AddIns/FusionBridge` | | Windows (untested) | `%APPDATA%\Autodesk\Autodesk Fusion 360\API\AddIns\FusionBridge` | Use `--dir PATH` or the `FUSIONCTL_ADDINS_DIR` environment variable to install somewhere else. ### Restart Fusion [#restart-fusion] The add-in's manifest sets `runOnStartup`, so it starts with Fusion. Instead of restarting you can start it once from **Utilities > Add-Ins > FusionBridge > Run**. On its first start the add-in creates a random token in `~/.config/fusionctl/token` (file mode 600). The CLI reads the same file. ### Check the setup [#check-the-setup] ```sh fusionctl doctor ``` On a working install every line starts with `ok`. This was recorded with version 0.1.0 (the current release is 0.2.0), and the sign-in line is left out: ```txt title="fusionctl doctor" ok add-in installed v0.1.0 ok bridge reachable Fusion 2705.1.30 ok running add-in version v0.1.0 ok orientation Z-up (affects which plane is the floor) ``` If a line says `FAIL`, its message says what to do. [Troubleshooting](/docs/troubleshooting) lists each case. ## Updating [#updating] Run the same `bun install -g` command again, then `fusionctl install` to copy the new add-in, and restart the add-in (or Fusion). `fusionctl doctor` compares the add-in version on disk and the one running in Fusion with the version the CLI ships. ## From a checkout [#from-a-checkout] ```sh git clone https://github.com/pieralukasz/fusionctl cd fusionctl bun install bun link # puts this checkout's fusionctl on your PATH bun run compile # optional: a standalone binary in dist/fusionctl, add-in embedded ``` `bun run check` runs the type check, Biome and the tests. The tests use a mock bridge and do not need Fusion. ## Settings [#settings] | Variable | Default | Used by | | ---------------------- | ---------------------- | -------------------------------------- | | `FUSIONCTL_PORT` | `18765` | CLI and add-in | | `FUSIONCTL_CONFIG_DIR` | `~/.config/fusionctl` | CLI and add-in (where the token lives) | | `FUSIONCTL_ADDINS_DIR` | Fusion's add-in folder | `install`, `doctor` | | `NO_COLOR` | unset | CLI: plain tables without colour | The add-in reads `FUSIONCTL_PORT` and `FUSIONCTL_CONFIG_DIR` from Fusion's environment, so set them for Fusion too if you change them. # fusionctl and MCP (/docs/mcp) Since May 2026 Autodesk ships an official **Fusion MCP Server**: a Model Context Protocol endpoint that runs inside the Fusion you have open, so an MCP client can drive it. fusionctl does a similar job from a different side: a command-line tool and a small add-in that any program with a shell can call. Both work against a live Fusion session, and they do not exclude each other. This page sticks to what each project documents. It does not compare speed, reliability or token use: those have not been measured here. ## What each one is [#what-each-one-is] **fusionctl** is a third-party CLI for [Bun](https://bun.sh), with the FusionBridge add-in that `fusionctl install` copies into Fusion. Each command is one HTTP request to `127.0.0.1:18765`, run on Fusion's main thread ([How it works & security](/docs/how-it-works)). It uses the regular Fusion API for add-ins. **Autodesk Fusion MCP Server** is built into Fusion and turned on in **Preferences > General > API**. Autodesk's overview describes it as running "locally, on the same machine as Fusion" for "real-time modeling and command execution in a live Fusion session", with Claude Desktop, Cursor and "any MCP-capable HTTP client" as clients. Autodesk's [add-in tutorial](https://www.autodesk.com/products/fusion-360/blog/build-your-own-fusion-add-ins-with-the-fusion-mcp/) connects Cursor to `http://127.0.0.1:27182`, the default port it shows. The same post mentions a Claude Desktop connector for Fusion, which it calls "an official, low-setup integration". **Autodesk Fusion Data MCP Server** is a separate, cloud server for Fusion data (hubs, projects, folders, items). It does not need Fusion running and signs in with your Autodesk account. fusionctl has nothing like it. **Community MCP servers** exist as well, for example [frankhommers/autodesk-fusion-mcp](https://github.com/frankhommers/autodesk-fusion-mcp) (an add-in serving MCP over Streamable HTTP, with Python execution, viewport capture and API documentation lookup) and Autodesk's MIT-licensed reference sample [FusionMCPSample](https://github.com/AutodeskFusion360/FusionMCPSample) (`execute_api_script`, `get_screenshot`, `get_api_documentation`). fusionctl's `apidoc` search is adapted from that sample. ## Side by side [#side-by-side] | | fusionctl | Fusion MCP Server (Autodesk) | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Made by | Lucas Piera; unofficial, one maintainer, not affiliated with Autodesk | Autodesk, as part of Fusion | | Set up | `bun install -g github:pieralukasz/fusionctl`, `fusionctl install`, restart Fusion | Enable it in Preferences > General > API, then point a client at it | | Who can call it | Anything that runs a command: coding agents with a shell (Claude Code, Codex, OpenCode), shell scripts, Makefiles, CI, a person | MCP clients: Claude Desktop, Cursor, any MCP-capable HTTP client, per Autodesk | | How the caller learns it | `fusionctl --help` and `fusionctl --help` when needed; [AGENTS.md](https://github.com/pieralukasz/fusionctl/blob/main/AGENTS.md) and [llms-full.txt](/llms-full.txt) | "Your client discovers available tools at connection time, and the tool set may evolve between releases" | | Running Python | `exec` and `run`, from a string or a file | Per Autodesk's add-in tutorial, agents can "run test scripts in your active session" | | API documentation | `fusionctl apidoc TERM` searches the installed build | Per Autodesk, the tools "pull current documentation" | | Output | Tables on a terminal, JSON when piped or with `--json`; exit codes `0`, `1`, `2` | MCP tool results, shown in the client | | Checks | `timeline` (health), `bodies` (volume and bounding box in world mm), `interference`, `screenshot` from eight named views, `--atomic` rollback | Whatever tools the server offers in your release | | Authentication | Loopback only, token file (mode 600) on every request; requests with an `Origin` header are refused | "None — local connection only", per Autodesk's overview table | | Platforms | Checked on macOS (Fusion 2705.1.30); the Windows path is in the code but untested | Fusion desktop; see the note on availability below | | Cloud data | No | Through the separate Fusion Data MCP | | Support | GitHub issues, one maintainer | Autodesk | ## When the Fusion MCP is the better choice [#when-the-fusion-mcp-is-the-better-choice] * **You work in a chat app without a terminal.** Claude Desktop with Autodesk's Fusion connector needs no shell; fusionctl needs one. * **You want official support.** It is Autodesk's server, documented in Fusion's help, with nothing third-party to install. * **You want Autodesk to maintain the tools.** The tool set ships with Fusion; Autodesk notes it "may evolve between releases". * **You need cloud data.** Listing hubs and projects without Fusion running is what the Fusion Data MCP is for. ## When fusionctl fits [#when-fusionctl-fits] * **The agent already works in a shell.** Claude Code, Codex and OpenCode run `fusionctl` like any other command, with no MCP configuration. So can a Makefile or a CI job. * **The modelling should be files.** Scripts live in git, get reviewed like code, and `fusionctl run part.py --atomic` repeats them. Parameters change with `fusionctl params --set`. * **The agent must check its work.** Each step can end with `timeline` and `bodies`, which print numbers to compare against the spec, and `screenshot` for a picture. `--atomic` deletes what a failing script added. * **Help on demand.** The agent reads `fusionctl --help` when it needs a command, and [AGENTS.md](https://github.com/pieralukasz/fusionctl/blob/main/AGENTS.md) for the modelling rules. Coding agents that speak MCP, such as Cursor or Claude Code, can use either one; the choice there is about files, checks and support rather than about the client. ## Using both [#using-both] fusionctl's bridge is its own add-in on port `18765`; Autodesk's server is part of Fusion, on the port shown in its preferences (`27182` in Autodesk's tutorial). Both can be on at once. One way to split the work: explore in a chat with the MCP server, then keep what worked as a script that `fusionctl run` repeats and checks. ## Availability of the MCP setting [#availability-of-the-mcp-setting] Autodesk documents the setting at **Preferences > General > API**. One Reddit user reported in May 2026 that they could not find it with an education licence on a Mac, and did find it on Windows ([thread](https://www.reddit.com/r/Fusion360/comments/1t8qnhl/)). Whether every licence, including personal use, includes it is not confirmed here: check that menu on your own install. fusionctl only needs the regular API for add-ins, which the personal-use licence includes. ## Sources [#sources] * Autodesk, [Autodesk Fusion MCPs Overview](https://help.autodesk.com/view/fusion360/ENU/?guid=FMCP-OVERVIEW) (Fusion help): local and cloud servers, Preferences > General > API, authentication, compatible clients, dynamic tooling. * Autodesk Fusion blog, [Introducing the Fusion MCP: Opening Fusion to AI-Powered Workflows](https://www.autodesk.com/products/fusion-360/blog/introducing-the-fusion-mcp-opening-fusion-to-ai-powered-workflows/), 7 May 2026. * Autodesk Fusion blog, [Build Your Own Fusion Add-ins with the Fusion MCP](https://www.autodesk.com/products/fusion-360/blog/build-your-own-fusion-add-ins-with-the-fusion-mcp/), 30 June 2026: the Claude Desktop connector, running test scripts in the active session, current documentation, port `27182`. * r/Fusion360, [user report on the MCP setting](https://www.reddit.com/r/Fusion360/comments/1t8qnhl/), May 2026. * [frankhommers/autodesk-fusion-mcp](https://github.com/frankhommers/autodesk-fusion-mcp) and [AutodeskFusion360/FusionMCPSample](https://github.com/AutodeskFusion360/FusionMCPSample) on GitHub. * fusionctl: [README](https://github.com/pieralukasz/fusionctl#readme), [How it works & security](/docs/how-it-works), [Commands](/docs/commands). fusionctl is not affiliated with or endorsed by Autodesk. Autodesk and Fusion are trademarks of Autodesk, Inc. # Quickstart (/docs/quickstart) This walkthrough builds `examples/bracket.py`, an L-bracket for FDM printing driven by seven user parameters. Every block of output below was copied from a terminal on Fusion 2705.1.30 (macOS, October 2026); only the colours were removed. Get the script from the repository: ```sh curl -O https://raw.githubusercontent.com/pieralukasz/fusionctl/main/examples/bracket.py ``` ## Check the session [#check-the-session] ```txt title="fusionctl ping" fusion 2705.1.30 document Untitled design parametric units mm orientation Z-up bridge 0.1.0 ``` Read `orientation` first. The bracket script assumes a **Z-up** document: the base lies on the XY plane and the wall rises along +Z. On a Y-up install the floor is XZ, and the script would build the part lying on its side. ## Build the bracket [#build-the-bracket] ```sh fusionctl new fusionctl run bracket.py --atomic ``` `new` opens a parametric design in millimetres. `run` sends the file to Fusion and prints what the script printed, then the `result` variable as JSON. The script ends with `result = {"bend_edges": bend.count, "bed_edges": bed.count}`: ```json title="fusionctl run bracket.py --atomic" { "bend_edges": 1, "bed_edges": 4 } ``` One edge got the bend fillet and four edges on the bed got the chamfer, which is what the script intended. `--atomic` would have deleted the timeline items the script added if it had failed; see [Verifying a part](/docs/verifying#when-a-script-fails). ## Read it back [#read-it-back] The timeline: seven features, all `ok`. ```txt title="fusionctl timeline" # feature type health message 0 L profile Sketch ok 1 Bracket body ExtrudeFeature ok 2 Hole points Sketch ok 3 Base holes HoleFeature ok 4 Wall hole HoleFeature ok 5 Bend fillet FilletFeature ok 6 Bed chamfer ChamferFeature ok ``` The body, in world coordinates and millimetres: ```txt title="fusionctl bodies" body where solid volume mm³ size mm min mm faces Bracket root yes 6168.75 40 x 24 x 30 0 x -12 x 0 16 ``` 40 mm long, 24 mm wide, 30 mm tall, centred on Y, sitting on the bed at z = 0. The parameters that drive it (recorded with 0.1.0; from 0.2.0 the table shows a formatted `value` column, e.g. `40.00 mm`, instead of `unit`): ```txt title="fusionctl params" name expression unit kind length 40 mm mm user height 30 mm mm user width 24 mm mm user thick 4 mm mm user hole_d 4.5 mm mm user bend_r 3 mm mm user bed_chamfer 0.6 mm mm user ``` ## Look at it [#look-at-it] ```sh fusionctl screenshot bracket-iso.png --view iso --size 1600x1200 fusionctl screenshot bracket-front.png --view front --size 1600x1200 fusionctl screenshot bracket-top.png --view top --size 1600x1200 fusionctl screenshot bracket-iso-bottom.png --view iso-bottom --size 1600x1200 ``` Each named view fits the model before saving. The front view shows the L profile with the 3 mm bend fillet; the top view shows the two base holes at the same X, a quarter of the width either side of the centre line. ## Change a parameter [#change-a-parameter] ```sh fusionctl params --set length=55mm hole_d=5.5mm ``` The timeline regenerates. The base holes are dimensioned with the expression `thick + (length - thick) / 2`, so they move with the length, from x = 22 mm to x = 29.5 mm. All three holes grow from M4 to M5 clearance. ```txt title="fusionctl bodies" body where solid volume mm³ size mm min mm faces Bracket root yes 7509.1 55 x 24 x 30 0 x -12 x 0 16 ``` Same face count, longer bounding box, larger volume: the change did what it should and nothing else. ## Check and export [#check-and-export] ```txt title="fusionctl interference" No interference between 1 solid body. ``` With a single body there is nothing to collide; the command earns its keep on assemblies. ```sh fusionctl export bracket.step # the source of truth fusionctl export bracket.3mf # for the slicer ``` The format follows the extension: `.step` (or `.stp`), `.stl`, `.3mf` or `.f3d`. The files exported from this session, at the 40 mm length, are here: [bracket.step](https://fusionctl.lucaspiera.com/files/bracket.step) and [bracket.3mf](https://fusionctl.lucaspiera.com/files/bracket.3mf). ## Next [#next] * [Scripting in Fusion](/docs/scripting) explains the globals, the units and the sketch-plane axes the script relies on. * [Verifying a part](/docs/verifying) turns the checks above into a routine. # Scripting in Fusion (/docs/scripting) `fusionctl exec` and `fusionctl run` send Python to the add-in, which runs it on Fusion's main thread with the full `adsk` API. These notes were checked on Fusion 2705.1.30. ## Globals [#globals] | Name | What it is | | ------------ | ------------------------------------------------------------------------------ | | `app` | `adsk.core.Application` | | `ui` | `app.userInterface` (do not open dialogs with it, see below) | | `design` | the active `adsk.fusion.Design` | | `root` | `design.rootComponent` | | `adsk` | the `adsk` package (`adsk.core`, `adsk.fusion`, …) | | `mm(v)` | millimetres to the API's centimetres: `mm(5)` returns `0.5` | | `P(x, y, z)` | `adsk.core.Point3D.create` | | `V(v)` | `adsk.core.ValueInput.createByReal` (API units) | | `VS(s)` | `adsk.core.ValueInput.createByString`, e.g. `VS("12 mm")` or `VS("width / 2")` | Anything else a script defines (variables, functions) stays available to the next `exec` or `run` until `fusionctl reset` or `fusionctl new`. The globals above are refreshed on every call, so `design` and `root` always point at the active document. ## Returning values [#returning-values] `print()` output is captured and printed by the CLI. Set `result` to return a value as JSON: ```sh fusionctl exec 'result = {"bodies": root.bRepBodies.count, "features": design.timeline.count}' ``` A value that is not JSON-serialisable is returned as its `repr()`. With `--json`, the CLI prints the whole reply, `stdout` and `result` included. ## Units [#units] The API works in **centimetres** and radians, whatever the document's display units. Writing millimetre numbers into `Point3D` makes the part ten times too large and raises no error. ```python P(mm(40), 0, 0) # 40 mm along X VS("12 mm") # a length from an expression, stays parametric VS("width / 2") # an expression over user parameters ``` Prefer `VS(...)` with an expression for feature sizes, so the feature follows the parameters. ## Sketch planes [#sketch-planes] For sketches created through the API on the origin planes: | Plane | sketch +X | sketch +Y | extrude (+) | | --------------------- | ------------ | ------------ | ----------- | | `xYConstructionPlane` | world +X | world +Y | +Z | | `xZConstructionPlane` | world +X | **world −Z** | +Y | | `yZConstructionPlane` | **world −Z** | world +Y | +X | On XZ, a point at world height `z` is `P(x, -z, 0)` in sketch coordinates. The bracket script keeps that in one helper: ```python xz = lambda x, z: P(x, -z, 0) # world (x, z) -> XZ sketch coordinates ``` Sketches on body faces choose their own axes: check `sketch.xDirection` or convert with `sketch.modelToSketchSpace(...)`. ## Orientation [#orientation] `fusionctl ping` reports `orientation`. On a **Z-up** install the print bed is the XY plane and +Z is up. On a **Y-up** install the floor is XZ. The bracket script assumes Z-up; check before you reuse it. ## Parameters [#parameters] Create user parameters first and refer to them by name in dimensions and features. Then `fusionctl params --set` changes the part and the timeline regenerates. ```python title="examples/bracket.py" params = { "length": "40 mm", # base, along X "height": "30 mm", # wall, along Z "width": "24 mm", # along Y "thick": "4 mm", "hole_d": "4.5 mm", # M4 clearance, ISO 273 medium "bend_r": "3 mm", # inner fillet: stiffens the bend "bed_chamfer": "0.6 mm" # chamfer, not fillet, on edges touching the bed (elephant foot) } for name, expr in params.items(): p = design.userParameters.itemByName(name) if p is None: design.userParameters.add(name, VS(expr), "mm", "") ``` The `itemByName` check makes the script safe to run twice in the same design. ## Recipes from the bracket [#recipes-from-the-bracket] ### Dimension a sketch to a parameter [#dimension-a-sketch-to-a-parameter] Place the geometry at its current size, then dimension it and set the dimension's expression: ```python d = dims.addDistanceDimension(seg.startSketchPoint, seg.endSketchPoint, orient, at) d.parameter.expression = "length" ``` ### Extrude symmetrically by a parameter [#extrude-symmetrically-by-a-parameter] ```python ext_in = root.features.extrudeFeatures.createInput( sk.profiles.item(0), adsk.fusion.FeatureOperations.NewBodyFeatureOperation) ext_in.setSymmetricExtent(VS("width"), True) ext = root.features.extrudeFeatures.add(ext_in) ``` ### Holes that follow the parameters [#holes-that-follow-the-parameters] A bare sketch point does not move when parameters change. Dimension it to the sketch origin with an expression: ```python hs.sketchDimensions.addDistanceDimension( hs.originPoint, a, H_, P(x0, -W / 2 - 0.5, 0) ).parameter.expression = "thick + (length - thick) / 2" ``` Two sketch points that share an X cannot both get a horizontal dimension from the origin: Fusion reports `VCS_SKETCH_OVER_CONSTRAINTS` with no visible constraint. Dimension the first point fully, give the second only its Y, then tie the X: ```python hs.geometricConstraints.addVerticalPoints(a, b) ``` A hole drills against its sketch normal. From a sketch on the XY plane at the bed, that is −Z, away from the part. Drill up through the base with: ```python h_in.setAllExtent(adsk.fusion.ExtentDirections.NegativeExtentDirection) ``` ### Find a face or an edge by geometry [#find-a-face-or-an-edge-by-geometry] ```python def face_at(normal, pred): for f in body.faces: ok, n = f.evaluator.getNormalAtPoint(f.pointOnFace) if ok and n.isEqualTo(normal) and pred(f.pointOnFace): return f raise RuntimeError("face not found") outer = face_at(adsk.core.Vector3D.create(-1, 0, 0), lambda p: abs(p.x) < 1e-6) ``` The script selects the bend edge (a line along Y at x = z = `thick`) and the bed edges (lines at z = 0) the same way, then fillets and chamfers them with `VS("bend_r")` and `VS("bed_chamfer")`. It returns the edge counts in `result`, so a wrong selection shows up as a number. ## Things that break a session [#things-that-break-a-session] * **Modal dialogs.** `ui.messageBox`, selection prompts and other dialogs block Fusion's main thread, so the bridge cannot answer and the CLI times out. Print instead. * **Occurrences.** New occurrences are grounded to their parent: setting `occurrence.transform2` is ignored until `occurrence.isGroundToParent = False`. Then call `design.snapshots.add()` if `design.snapshots.hasPendingSnapshot`. * **3MF export.** The API method is `createC3MFExportOptions`. `fusionctl export part.3mf` handles it. * **Renamed methods.** Older samples use names that have changed. Check with `fusionctl apidoc ExtrudeFeatures.addSimple` against the installed build instead of guessing. # Troubleshooting (/docs/troubleshooting) Start with `fusionctl doctor`. It checks the add-in on disk, the live bridge, the running add-in's version, the sign-in and the model orientation, and exits with `1` if any check fails. A healthy setup: ```txt title="fusionctl doctor" ok add-in installed v0.1.0 ok bridge reachable Fusion 2705.1.30 ok running add-in version v0.1.0 ok orientation Z-up (affects which plane is the floor) ``` ## An error in one line [#an-error-in-one-line] When the add-in refuses a request on purpose, the CLI prints one line, `error: : `, and exits with `1`: ```txt error: undo: only 3 timeline item(s) exist ``` Any other exception inside Fusion prints `Fusion raised an error ().` with Fusion's traceback. A failing script has its own format, see [A script fails](#a-script-fails) below. ## The add-in is missing [#the-add-in-is-missing] `add-in installed` fails with `not found in : run fusionctl install`. Run `fusionctl install`, then restart Fusion or start the add-in from **Utilities > Add-Ins > FusionBridge > Run**. ## The add-in is outdated [#the-add-in-is-outdated] There are two version checks, because the copy on disk and the copy running in Fusion can differ: * `add-in installed` fails with `v on disk, CLI ships v`: you updated the CLI but not the add-in. Run `fusionctl install` and restart the add-in. * `running add-in version` fails with `running v`: the files on disk are new but Fusion still runs the old add-in. Restart it from **Utilities > Add-Ins**, or restart Fusion. `fusionctl ping` shows the running version in its `bridge` line. An add-in from before version reporting shows `unknown (older add-in)`. ## The bridge is unreachable [#the-bridge-is-unreachable] The CLI says `Bridge unreachable on 127.0.0.1:18765.` Fusion is not running, the add-in is not running, or the two use different ports. Start Fusion, check the add-in in **Utilities > Add-Ins**, and if you set `FUSIONCTL_PORT`, make sure Fusion sees the same value. If it says `No bridge token at …/token.`, the add-in has not created the token yet. The add-in creates the token on its first start, so it has never run with this config folder: start it, or check `FUSIONCTL_CONFIG_DIR`. ## The token is rejected [#the-token-is-rejected] The CLI says `Bridge rejected the token.` The add-in and the CLI read different token files. This happens when `FUSIONCTL_CONFIG_DIR` is set in your shell but not for Fusion (or the other way round). Make them match, then restart the add-in so it reads the token again. ## A command hangs, then times out [#a-command-hangs-then-times-out] The add-in waits 600 seconds for Fusion, then the command fails with `timeout (modal dialog open in Fusion?)`; if the add-in does not answer at all, the CLI gives up a little later with `Fusion did not answer in time.` In both cases a modal dialog is usually open in Fusion: a message box, a selection prompt, a sign-in or update window, or a dialog a script opened with `ui.messageBox`. While it is open Fusion's main thread does not run the bridge's requests. Close the dialog and run the command again. Scripts should print instead of opening dialogs. ## A script fails [#a-script-fails] The CLI prints `error: Script failed.`, the traceback limited to your script's frames, and exits with `1`. With `--atomic` it also lists the timeline items it rolled back. See [When a script fails](/docs/verifying#when-a-script-fails). ## The part comes out ten times too large [#the-part-comes-out-ten-times-too-large] The API works in centimetres. Wrap millimetre numbers in `mm()` or use `VS("12 mm")`. See [Units](/docs/scripting#units). ## The part is lying on its side [#the-part-is-lying-on-its-side] The script assumed the other model orientation. Check `orientation` in `fusionctl ping`: on Z-up the bed is XY, on Y-up it is XZ. ## timeline says the design has no timeline [#timeline-says-the-design-has-no-timeline] `direct-modeling design has no timeline`: the active document is a direct-modelling design. Start a parametric one with `fusionctl new`. # Verifying a part (/docs/verifying) Work in small steps and check each one before the next. This is the routine the [rules for agents](/docs/agents) ask for, and it works the same by hand. ## One step at a time [#one-step-at-a-time] Put one feature, or a few related ones, in each script and run it with `--atomic`: ```sh fusionctl run step-3-holes.py --atomic ``` If the script fails, the timeline items it added are deleted, newest first, and the model is back where it was before the step. ## When a script fails [#when-a-script-fails] ```txt title="fusionctl exec '…' --atomic (exit code 1)" error: Script failed. Traceback (script frames only): File "", line 4, in root.features.extrudeFeatures.addSimple(sk.profiles.item(1), VS("10 mm"), adsk.fusion.FeatureOperations.NewBodyFeatureOperation) ~~~~~~~~~~~~~~~~^^^ RuntimeError: 3 : Bad index parameter --atomic: rolled back 1 timeline item(s): Sketch1 ``` The traceback shows only the frames of your script (`` for inline `exec` code, the file path for `run`). Here the script asked for a second profile in a sketch that has one; the sketch it had already created, `Sketch1`, was removed. The exit code is `1`. ## After every step [#after-every-step] ### The timeline [#the-timeline] Every item should be `ok`. A `warning` or `error` comes with Fusion's message in the last column. ```txt title="fusionctl timeline" # feature type health message 0 L profile Sketch ok 1 Bracket body ExtrudeFeature ok 2 Hole points Sketch ok 3 Base holes HoleFeature ok 4 Wall hole HoleFeature ok 5 Bend fillet FilletFeature ok 6 Bed chamfer ChamferFeature ok ``` ### The bodies [#the-bodies] The body count, volume and bounding box should change the way the step intended. An operation that changed nothing failed silently. ```txt title="fusionctl bodies" body where solid volume mm³ size mm min mm faces Bracket root yes 6168.75 40 x 24 x 30 0 x -12 x 0 16 ``` Measure the dimensions that matter against the spec. The bracket should be 40 x 24 x 30 mm and sit on the bed: `min mm` says z starts at 0. `solid` must be `yes` for anything you print. After `params --set length=55mm hole_d=5.5mm` the same row reads `7509.1` and `55 x 24 x 30` with 16 faces: longer, same topology. ### Interference [#interference] For assemblies, check that no two visible solid bodies overlap: ```txt title="fusionctl interference" No interference between 1 solid body. ``` Zero overlap does not prove a fit. A peg can miss its hole by 2 mm without touching it: measure the gaps too, for example with a small `exec` script that compares bounding boxes. ### Screenshots [#screenshots] Look before you say it is done. An isometric view plus `front` and `top` catches most mistakes: a hole on the wrong side, a fillet on the wrong edge, a part lying on its side. ```sh fusionctl screenshot shot.png --view iso fusionctl screenshot front.png --view front fusionctl screenshot top.png --view top ``` A coding agent can read these PNG files back as images. ## Fix the feature, not the symptom [#fix-the-feature-not-the-symptom] When a native feature fails (a fillet, a pattern, a shell), fix it rather than replacing it with many manual cuts. The workaround leaves a timeline nobody can edit, and `params --set` stops working for that part of the model. ## Before printing [#before-printing] * Export STEP as the source of truth, 3MF or STL for the slicer. * Print a small test piece of any critical fit (a hole for a bearing, a snap, a thread) before the whole part. * The bracket uses a 0.6 mm chamfer, not a fillet, on the edges that touch the bed, against elephant's foot.