app
Adom Desktop - Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Desktop: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058
"""Electronics commands — run EAGLE commands via Electron.run text command.
Fusion 360's Electronics workspace (inherited from EAGLE) has no Python API,
but we can use `app.executeTextCommand('Electron.run <cmd>')` to send EAGLE
editor commands into the electronics workspace.
IMPORTANT: The user must be in an Electronics editor (schematic, board, or library)
for Electron.run commands to work. The "Electronics Design" overview/container
workspace does NOT count — the user must have a schematic or board open and active.
Workspace hierarchy:
"Design" — Normal 3D CAD (Electron.run fails)
"Electronics Design" — Container/overview (Electron.run fails)
"Schematic Editor" — Schematic editing (Electron.run works)
"Board Layout" — PCB layout editing (Electron.run works)
"Electronics Library" — Library editing (Electron.run works)
Commands that BLOCK (open dialogs — avoid from automation):
WRITE — Opens Save As dialog
ADD — Opens component picker dialog
SHOW <name> — Opens interactive highlight mode
GRID (no params) — Opens grid settings dialog
EDIT <new>.sym — Opens "Create new?" confirmation dialog
Safe automation patterns:
SCRIPT '<path>.scr' — Runs batch commands from file (non-interactive)
EXPORT SCRIPT — Exports library/schematic to .scr file (for verification)
DISPLAY — Shows/hides layers (non-interactive)
WINDOW FIT — Zoom to fit
EDIT <existing>.sym — Opens existing symbol (no dialog)
SET CONFIRM YES — In .scr: suppresses create-new confirmations
"""
import adsk.core
import glob
import os
import re
import tempfile
def _get_fusion_log_path() -> str | None:
"""Find the latest Fusion 360 log file."""
log_dir = os.path.join(
os.environ.get("LOCALAPPDATA", ""),
"Autodesk", "Autodesk Fusion 360",
)
# Find user-specific log dir (e.g. X3G293QFLPJERKGZ/logs/)
for entry in os.listdir(log_dir):
candidate = os.path.join(log_dir, entry, "logs")
if os.path.isdir(candidate):
logs = sorted(glob.glob(os.path.join(candidate, "AppLogFile*.log")),
key=os.path.getmtime, reverse=True)
if logs:
return logs[0]
return None
def _get_log_line_count(log_path: str) -> int:
"""Get current line count of a log file (fast — seeks to end)."""
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
return sum(1 for _ in f)
except Exception:
return 0
def _check_log_for_alerts(log_path: str, after_line: int) -> list[str]:
"""Check Fusion log for 'New Alert message' entries after a given line number.
Returns list of alert message texts (the error toast content).
"""
alerts = []
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f, 1):
if i <= after_line:
continue
if "New Alert message" in line:
# Extract: "Tooltip: <msg>, Short Message: <msg>"
m = re.search(r"Short Message:\s*(.+?)(?:,\s*Long Message:|$)", line)
if m:
alerts.append(m.group(1).strip())
else:
# Fallback: grab everything after "Tooltip: "
m2 = re.search(r"Tooltip:\s*(.+?)(?:,|$)", line)
if m2:
alerts.append(m2.group(1).strip())
except Exception:
pass
return alerts
def _extract_export_path(eagle_cmd: str) -> str | None:
"""Extract the output file path from an EAGLE EXPORT command.
EAGLE EXPORT commands use single-quoted paths:
EXPORT NETLIST 'C:/tmp/netlist.txt'
EXPORT IMAGE 'C:/tmp/board.png' 300
EXPORT PARTLIST 'C:/tmp/parts.txt'
Returns the unquoted path, or None if not found.
"""
# Match single-quoted path
m = re.search(r"'([^']+)'", eagle_cmd)
if m:
return m.group(1)
# Fallback: look for unquoted path-like tokens (with / or \ and an extension)
parts = eagle_cmd.strip().split()
for part in parts[2:]: # Skip "EXPORT" and subcommand
cleaned = part.strip("'\"")
if ("/" in cleaned or "\\" in cleaned) and "." in cleaned:
return cleaned
return None
def _get_workspace_info(app: adsk.core.Application) -> dict:
"""Get current workspace info and whether Electronics Electron editor is active."""
try:
active_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")
except Exception:
active_ws = "unknown"
is_electron = False
try:
app.executeTextCommand("Electron.run DISPLAY")
is_electron = True
except Exception:
pass
# Get document info
doc_info = None
try:
doc = app.activeDocument
if doc:
doc_info = {"name": doc.name, "type": str(doc.documentType)}
except Exception:
pass
# Get product type
product_type = None
try:
product = app.activeProduct
if product:
product_type = product.productType
except Exception:
pass
info = {
"activeWorkspace": active_ws.strip() if active_ws else "unknown",
"isElectronics": is_electron,
}
if doc_info:
info["document"] = doc_info
if product_type:
info["productType"] = product_type
return info
def _get_recent_operations(app: adsk.core.Application, count: int = 5) -> list:
"""Get recent Fusion operations log via Diagnostics.RecentOperations."""
try:
result = app.executeTextCommand(f"Diagnostics.RecentOperations {count}")
if result:
lines = [l.strip() for l in result.strip().split("\r\n") if l.strip()]
# Filter to just command begin/end, skip DLL loads
return [l for l in lines if l.startswith("Command ")]
return []
except Exception:
return []
def _query_post_command_state(app: adsk.core.Application, eagle_cmd: str, ops_before: list) -> dict:
"""Query Fusion state after an Electron.run command to return meaningful feedback.
Uses Diagnostics.RecentOperations to diff what actually happened, plus
workspace/document state for context.
"""
import time
time.sleep(0.3) # Brief pause for command to take effect
state = {}
# Always include workspace info
try:
active_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")
state["activeWorkspace"] = active_ws.strip() if active_ws else "unknown"
except Exception:
state["activeWorkspace"] = "unknown"
# Active document
try:
doc = app.activeDocument
if doc:
state["activeDocument"] = doc.name
except Exception:
pass
# Diff operations log — what commands fired as a result?
ops_after = _get_recent_operations(app, 10)
if ops_before and ops_after:
# Find new operations that weren't in the before snapshot
new_ops = []
for op in ops_after:
if op not in ops_before:
new_ops.append(op)
if new_ops:
state["fusionOperations"] = new_ops[:5] # Cap at 5 to keep response size sane
# Parse command to provide targeted feedback
cmd_parts = eagle_cmd.strip().split(None, 1)
cmd_verb = cmd_parts[0].upper() if cmd_parts else ""
cmd_upper = eagle_cmd.strip().upper()
# Command-specific hints and extra queries
if cmd_verb == "DISPLAY":
state["commandType"] = "layer_visibility"
state["hint"] = "DISPLAY command executed. Use fusion_take_screenshot to verify layer changes visually."
elif cmd_verb == "WINDOW":
state["commandType"] = "view_control"
state["hint"] = "Zoomed to fit all content in view." if "FIT" in cmd_upper else "View position/zoom changed."
elif cmd_verb == "EDIT":
target = cmd_parts[1] if len(cmd_parts) > 1 else ""
state["commandType"] = "edit"
ext_map = {".sym": "symbol", ".pac": "package", ".dev": "device"}
for ext, editor in ext_map.items():
if target.endswith(ext):
state["hint"] = f"Opened {editor} editor for: {target}"
state["editorType"] = editor
break
else:
state["hint"] = f"Opened editor for: {target}"
elif cmd_verb == "RATSNEST":
state["commandType"] = "ratsnest"
state["hint"] = "Ratsnest (airwires) recalculated."
elif cmd_verb == "BOARD":
state["commandType"] = "view_switch"
state["hint"] = "Requested switch to board layout. Verify with a screenshot."
elif cmd_verb == "SET":
state["commandType"] = "setting"
state["hint"] = f"Setting changed: {eagle_cmd.strip()}"
elif cmd_verb == "SCRIPT":
state["commandType"] = "script"
state["hint"] = f"Script executed: {cmd_parts[1] if len(cmd_parts) > 1 else '(unknown)'}"
elif cmd_verb == "USE":
state["commandType"] = "library_load"
state["hint"] = f"Library load requested: {cmd_parts[1] if len(cmd_parts) > 1 else '(unknown)'}"
elif cmd_verb in ("NAME", "VALUE", "SMASH", "MOVE", "ROTATE", "DELETE", "COPY", "GROUP",
"ROUTE", "RIPUP", "WIRE", "CIRCLE", "RECT", "POLYGON", "TEXT", "ARC",
"PAD", "SMD", "VIA", "HOLE", "PIN", "LABEL", "NET", "BUS", "JUNCTION"):
state["commandType"] = "design_edit"
state["hint"] = f"Design edit command executed: {cmd_verb}"
elif cmd_verb == "EXPORT":
state["commandType"] = "export"
# Try to verify the export actually produced a file
# Parse output path from command: EXPORT <TYPE> <path> [options]
export_parts = eagle_cmd.strip().split()
export_path = None
if len(export_parts) >= 3:
# The path is typically the 3rd token (after EXPORT <TYPE>)
candidate = export_parts[2]
# Handle quoted paths
if candidate.startswith(("'", '"')):
quote = candidate[0]
rest = eagle_cmd.strip().split(quote)
if len(rest) >= 3:
export_path = rest[1]
else:
export_path = candidate
if export_path:
import os
if os.path.exists(export_path):
fsize = os.path.getsize(export_path)
state["hint"] = f"Export succeeded: {export_path} ({fsize} bytes)"
state["exportedFile"] = export_path
state["exportedFileSize"] = fsize
else:
state["hint"] = (
f"Export command ran but output file NOT found at: {export_path}. "
"The EAGLE command may have failed silently — check Fusion's error toast or operations log."
)
state["exportFailed"] = True
state["expectedPath"] = export_path
else:
state["hint"] = f"Export command executed: {eagle_cmd.strip()}"
else:
state["commandType"] = "generic"
state["hint"] = f"Command executed: {eagle_cmd.strip()}. Use fusion_take_screenshot to verify the result visually."
# If no Fusion operations fired, the command likely failed silently.
# EAGLE's Electron.run never returns errors — failures only show as
# toast dialogs on the user's screen that are invisible to the API.
# We check Fusion's error log for alert toasts (see handle_electron_run),
# but log writes are async so errors may appear after our check window.
if "fusionOperations" not in state or not state["fusionOperations"]:
state["possibleFailure"] = True
state["hint"] = (
f"WARNING: No Fusion operations detected after '{cmd_verb}'. "
f"The command likely failed silently. Check the 'alertErrors' field "
f"in this response — if populated, it contains the actual Fusion error "
f"message captured from the log. If 'alertErrors' is empty, the error "
f"may not have been logged yet; take a screenshot with "
f"fusion_take_screenshot to see the error toast in Fusion's UI. "
f"Original hint: {state.get('hint', '')}"
)
return state
def handle_electron_run(app: adsk.core.Application, args: dict) -> dict:
"""Execute an EAGLE command in the Electronics workspace via Electron.run.
Requires an active Electronics editor (schematic, board, or library).
Returns workspace state + Fusion operations log diff after execution.
Args:
args: Dict with:
- command: The EAGLE command to execute (required).
Examples: "EDIT resistor.sym", "USE mylib.lbr", "WINDOW FIT"
"""
eagle_cmd = args.get("command", "")
if not eagle_cmd:
return {"success": False, "error": "No command specified. Provide an EAGLE command string."}
# Check we're in Electronics before executing
ws_info = _get_workspace_info(app)
if not ws_info["isElectronics"]:
return {
"success": False,
"error": f"Not in Electronics editor (current: {ws_info['activeWorkspace']}). "
"Open a schematic or board in Fusion 360's Electronics workspace first.",
"_hint": "Call fusion_open_schematic {\"filePath\":\"...\"}, fusion_open_board {\"filePath\":\"...\"}, or fusion_open_lbr {\"filePath\":\"...\"} to enter the Electronics editor, then retry.",
"data": {
**ws_info,
"hint": "Use fusion_open_schematic, fusion_open_board, or fusion_open_lbr to open an electronics file first.",
},
}
# Parse the first word as the command verb for validation and routing.
cmd_verb = eagle_cmd.strip().split()[0].upper() if eagle_cmd.strip() else ""
# Block commands that don't work in Fusion's Electronics workspace or that
# pop up error dialogs on the user's screen with no useful result.
BLOCKED_COMMANDS = {
"SCHEMATIC": "Use fusion_show_2d_board or fusion_electron_run '{\"command\": \"BOARD\"}' to switch between schematic/board views. "
"The SCHEMATIC command doesn't exist in Fusion's EAGLE integration.",
"ADD": "Opens an interactive component picker dialog that blocks automation. "
"Place components through the Fusion UI instead.",
"SHOW": "Opens interactive highlight mode that blocks automation. "
"Use fusion_board_info to query component/net data instead.",
"CHANGE": "Opens interactive change mode that blocks automation.",
"MOVE": "Opens interactive move mode — requires clicking to place. "
"Not suitable for CLI automation. Use the Fusion API to move components programmatically.",
}
# WRITE always opens a blocking "Version Description" dialog on cloud docs,
# even when a path argument is provided. Tested 2026-04-10 — WRITE <path>
# still triggers the dialog. Block unconditionally.
if cmd_verb == "WRITE":
return {
"success": False,
"error": "Command 'WRITE' is blocked — it opens a blocking 'Version Description' save dialog "
"on cloud documents, even when a path argument is provided (tested 2026-04-10). "
"Use fusion_export_source for .fsch/.fbrd export, or fusion_export_eagle_source for .sch/.brd.",
"_hint": "For Fusion-format source: fusion_export_source {\"outputPath\":\"C:/out/file.fbrd\"}. "
"For EAGLE-format source: fusion_export_eagle_source {\"outputPath\":\"C:/out/file.brd\"}.",
"data": {
"command": eagle_cmd,
"blockedVerb": "WRITE",
"activeWorkspace": ws_info["activeWorkspace"],
},
}
if cmd_verb in BLOCKED_COMMANDS:
return {
"success": False,
"error": f"Command '{cmd_verb}' is blocked for automation: {BLOCKED_COMMANDS[cmd_verb]}",
"_hint": f"'{cmd_verb}' opens an interactive dialog. Use fusion_board_info / fusion_save_to_cloud / the Fusion API alternatives instead of this EAGLE command.",
"data": {
"command": eagle_cmd,
"blockedVerb": cmd_verb,
"hint": BLOCKED_COMMANDS[cmd_verb],
"activeWorkspace": ws_info["activeWorkspace"],
},
}
# Auto-suppress overwrite/confirmation dialogs for EXPORT commands.
# Without this, EAGLE shows a Qt "Overwrite?" dialog that blocks the
# add-in thread and is invisible to the CLI/AI — freezing everything.
export_target_path = None
if cmd_verb == "EXPORT":
try:
app.executeTextCommand("Electron.run SET CONFIRM YES")
except Exception:
pass # Best effort — don't block if SET fails
# Extract the output file path from the EXPORT command.
# EAGLE EXPORT syntax: EXPORT <type> '<path>' [options]
# Paths are typically single-quoted: EXPORT NETLIST '/path/to/file.txt'
export_target_path = _extract_export_path(eagle_cmd)
if export_target_path and os.path.exists(export_target_path):
try:
os.remove(export_target_path)
except PermissionError:
return {
"success": False,
"error": f"Cannot overwrite '{export_target_path}' — file is locked by another process.",
"_hint": "Choose a different outputPath (e.g., append a timestamp) and retry the EXPORT command.",
"data": {
"command": eagle_cmd,
"filePath": export_target_path,
"hint": "The target file exists and is locked. Close any program using it, "
"or choose a different output path.",
"nextSteps": [
f"Delete or rename '{export_target_path}' manually",
"Or use a different output path in the EXPORT command",
],
},
}
except Exception as e:
return {
"success": False,
"error": f"Cannot remove existing file '{export_target_path}': {e}",
"data": {
"command": eagle_cmd,
"filePath": export_target_path,
"hint": "Pre-delete failed. Use a different output path to avoid the overwrite dialog.",
},
}
# Snapshot operations log and Fusion error log BEFORE execution
ops_before = _get_recent_operations(app, 10)
log_path = _get_fusion_log_path()
log_line_before = _get_log_line_count(log_path) if log_path else 0
try:
raw_result = app.executeTextCommand(f"Electron.run {eagle_cmd}")
except Exception as e:
return {
"success": False,
"error": f"Electron.run failed: {e}",
"_hint": "Check the active workspace with fusion_execute_text_command {\"command\": \"DebugCommands.ActiveWorkspace\"} — EAGLE commands need the right editor (schematic vs board vs library). Call fusion_open_schematic / fusion_open_board / fusion_open_lbr to switch.",
"data": {
"command": eagle_cmd,
"hint": "The command may be invalid or require a specific editor context (schematic vs board vs library).",
},
}
# Query state after execution — includes ops log diff
post_state = _query_post_command_state(app, eagle_cmd, ops_before)
# Check Fusion's error log for alert messages that appeared after the command.
# These are the toast errors shown in the lower-right of Fusion's UI that
# Electron.run silently swallows — the only way to detect them is via the log.
# Fusion writes to the log asynchronously, so we poll with increasing delays.
alert_errors = []
if log_path:
import time
for _attempt in range(4):
time.sleep(0.3 if _attempt == 0 else 0.5)
alert_errors = _check_log_for_alerts(log_path, log_line_before)
if alert_errors:
break
# Detect export failures (command ran but output file not created)
succeeded = True
if post_state.get("exportFailed"):
succeeded = False
# If Fusion logged alert errors, the command failed
if alert_errors:
succeeded = False
post_state["alertErrors"] = alert_errors
post_state["hint"] = (
f"EAGLE error: {alert_errors[0]}. "
f"Original hint: {post_state.get('hint', '')}"
)
# For EXPORT commands, verify the output file exists and report size
export_data = {}
if cmd_verb == "EXPORT" and export_target_path:
import time
time.sleep(0.5) # Give EAGLE a moment to flush to disk
if os.path.exists(export_target_path):
fsize = os.path.getsize(export_target_path)
export_data = {
"exportedFile": export_target_path,
"fileSizeBytes": fsize,
"fileSizeKB": fsize // 1024,
}
succeeded = True # Override exportFailed if file actually exists
else:
succeeded = False
export_data = {
"exportedFile": export_target_path,
"exportFailed": True,
"hint": f"EXPORT command ran but file was not created at '{export_target_path}'. "
"This EXPORT subcommand may not be supported in the current editor context.",
}
result = {
"success": succeeded,
"error": alert_errors[0] if alert_errors else "",
"output": post_state.get("hint", f"Executed: {eagle_cmd}"),
"data": {
"command": eagle_cmd,
"rawResult": raw_result if raw_result else "",
"alertErrors": alert_errors,
**post_state,
**export_data,
},
}
# If we suspect failure but didn't catch a log error, hint the AI
# that errors from Electron.run are async — the real error may appear
# in the next command's alertErrors or in a screenshot.
if post_state.get("possibleFailure") and not alert_errors:
result["data"]["asyncErrorHint"] = (
"Electron.run is fire-and-forget — errors only appear as toast "
"dialogs in Fusion's UI. The error log was checked but nothing "
"appeared yet. Run fusion_take_screenshot to see the current "
"Fusion state and check for error toasts."
)
return result
def handle_open_electronics(app: adsk.core.Application, args: dict) -> dict:
"""Check Electronics workspace status and optionally open a file.
PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file.
Args:
args: Dict with:
- type: "status" to just check (default). Other values reserved
for future use (workspace switching handled by bridge server).
- filePath: Optional path to a .sch, .brd, or .lbr file to open
(only works if already in Electronics editor).
"""
file_path = args.get("filePath", "")
ws_info = _get_workspace_info(app)
# If already in electronics, handle file opening or return status
if ws_info["isElectronics"]:
if file_path:
try:
result = app.executeTextCommand(f"Electron.run EDIT '{file_path}'")
return {
"success": True,
"output": f"Opened {file_path} in Electronics workspace.",
"data": {"result": result if result else "", **ws_info},
}
except Exception as e:
return {
"success": False,
"error": f"Failed to open {file_path}: {e}",
"_hint": "Verify the file exists on the Windows host, then try fusion_open_schematic or fusion_open_board with the correct filePath.",
}
return {
"success": True,
"output": "Electronics editor is active. Electron.run commands will work.",
"data": ws_info,
}
# Not in Electronics — report status
return {
"success": True,
"output": f"Not in Electronics editor (current workspace: {ws_info['activeWorkspace']}).",
"data": ws_info,
}
def handle_export_source(app: adsk.core.Application, args: dict) -> dict:
"""Export the active electronics document as .fsch, .fbrd, or .flbr source file.
Uses Fusion's Document.CopyToDesktop text command, which exports the
currently active document in its native format based on the file extension.
PREREQUISITES — the caller MUST set up the correct view before calling:
To export .fbrd (board):
1. Open the .fprj: fusion_open_cloud_file {"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", ...}
2. Enter board view: fusion_show_2d_board
3. Export: fusion_export_source {"outputPath": "C:/out/MyDesign.fbrd"}
To export .fsch (schematic):
4. Switch to schematic: fusion_electron_run {"command": "EDIT .s1"}
5. Export: fusion_export_source {"outputPath": "C:/out/MyDesign.fsch"}
To export .flbr (library):
1. Open the .lbr: fusion_open_lbr {"filePath": "C:/path/to/lib.lbr"}
2. Export: fusion_export_source {"outputPath": "C:/out/MyLib.flbr"}
IMPORTANT GOTCHAS:
- You MUST open the .fprj file first, NOT .fbrd/.fsch directly
- You MUST call show_2d_board before exporting .fbrd (the 3D board view won't work)
- To switch between board and schematic: use EDIT .s1 (schematic) or EDIT .brd (board)
- The active workspace MUST be "Board Layout" for .fbrd or "Schematic Editor" for .fsch
- If the wrong workspace is active, the file will not be created
- After opening cloud file, wait for the document to fully load before exporting
- Always screenshot after open to check for blocking dialogs before exporting
Args:
args: Dict with:
- outputPath: Required. Full path including extension (.fsch, .fbrd, or .flbr).
The extension determines the export format.
"""
import os
output_path = args.get("outputPath", "").strip()
if not output_path:
return {
"success": False,
"error": "Missing required arg: outputPath",
"_hint": "Provide outputPath with extension .fsch, .fbrd, or .flbr. "
"For plain EAGLE format (.sch/.brd), use fusion_export_eagle_source instead.",
}
# Validate extension
ext = os.path.splitext(output_path)[1].lower()
valid_exts = {".fsch", ".fbrd", ".flbr"}
if ext not in valid_exts:
return {
"success": False,
"error": f"Invalid extension '{ext}'. Must be one of: {', '.join(sorted(valid_exts))}",
"_hint": "Use .fsch for schematic source, .fbrd for board source, .flbr for library source. "
"For plain EAGLE format (.sch/.brd), use fusion_export_eagle_source instead.",
}
# Ensure output directory exists
out_dir = os.path.dirname(output_path)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
# Normalize path for Fusion (use forward slashes)
fusion_path = output_path.replace("\\", "/")
try:
result = app.executeTextCommand(f"Document.CopyToDesktop {fusion_path}")
except Exception as e:
return {
"success": False,
"error": f"Document.CopyToDesktop failed: {e}",
"_hint": "Ensure the correct workspace is active: Board Layout for .fbrd, Schematic Editor for .fsch, Electronics Library for .flbr. Call fusion_show_2d_board or fusion_electron_run {\"command\":\"EDIT .s1\"} to switch.",
}
# Verify file was created
# Fusion may use the exact path or normalize it
check_path = output_path.replace("/", "\\")
if not os.path.exists(check_path) and not os.path.exists(output_path):
return {
"success": False,
"error": f"Command ran but file not found at {output_path}. "
"Make sure the correct editor is active (Board Layout for .fbrd, "
"Schematic Editor for .fsch).",
"_hint": "Call fusion_show_2d_board (for .fbrd) or fusion_electron_run {\"command\":\"EDIT .s1\"} (for .fsch) to activate the right workspace, then retry.",
"data": {"commandResult": result.strip() if result else ""},
}
actual_path = check_path if os.path.exists(check_path) else output_path
size_bytes = os.path.getsize(actual_path)
# Get active document context
doc_name = ""
workspace = ""
try:
doc = app.activeDocument
if doc:
doc_name = doc.name
workspace = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()
except Exception:
pass
return {
"success": True,
"output": f"Exported {ext} source ({size_bytes:,} bytes) to {output_path}",
"data": {
"savedTo": output_path.replace("\\", "/"),
"sizeBytes": size_bytes,
"format": ext.lstrip("."),
"activeDocument": doc_name,
"activeWorkspace": workspace,
},
}
def handle_export_eagle_source(app: adsk.core.Application, args: dict) -> dict:
"""Export the active electronics document as plain EAGLE .sch or .brd XML.
Fusion's .fsch/.fbrd files are ZIP containers with a real EAGLE .sch/.brd
XML file inside at `electron.BlobParts/ExtFile.*.{sch,brd}`. This command:
1. Exports the .fsch/.fbrd via Document.CopyToDesktop (same as fusion_export_source)
2. Extracts the inner EAGLE XML from the ZIP
3. Writes it to outputPath as a plain EAGLE file
4. Cleans up the intermediate .fsch/.fbrd
The resulting file is valid EAGLE XML (`<?xml ...><eagle version="9.7.0">...`)
that can be opened by standalone EAGLE, KiCad (via import), or parsed by any
XML tool.
PREREQUISITES — same as fusion_export_source:
Board (.brd): must be in "Board Layout" / "PCB Editor" workspace
Schematic (.sch): must be in "Schematic Editor" workspace
Args:
args: Dict with:
- outputPath: Required. Full path ending in .sch or .brd.
"""
import tempfile
import zipfile
output_path = args.get("outputPath", "").strip()
if not output_path:
return {"success": False, "error": "Missing required arg: outputPath"}
ext = os.path.splitext(output_path)[1].lower()
valid_exts = {".sch": ".fsch", ".brd": ".fbrd"}
if ext not in valid_exts:
return {
"success": False,
"error": f"Invalid extension '{ext}'. Must be .sch or .brd.",
"_hint": "For Fusion-format source (.fsch/.fbrd/.flbr), use fusion_export_source instead.",
}
fusion_ext = valid_exts[ext] # .sch -> .fsch, .brd -> .fbrd
# Export to a temp .fsch/.fbrd first
tmp_dir = tempfile.mkdtemp(prefix="adom-eagle-")
tmp_fusion_path = os.path.join(tmp_dir, f"temp{fusion_ext}")
fusion_path = tmp_fusion_path.replace("\\", "/")
try:
result = app.executeTextCommand(f"Document.CopyToDesktop {fusion_path}")
except Exception as e:
return {
"success": False,
"error": f"Document.CopyToDesktop failed: {e}",
"_hint": "Ensure the correct workspace is active: PCB Editor / Board Layout for .brd, "
"Schematic Editor for .sch. Use fusion_show_2d_board or "
"fusion_electron_run {\"command\":\"EDIT .s1\"} to switch.",
}
# Check if the file was created
check_path = tmp_fusion_path.replace("/", "\\")
actual_tmp = None
for p in [check_path, tmp_fusion_path]:
if os.path.exists(p):
actual_tmp = p
break
if not actual_tmp:
return {
"success": False,
"error": f"Document.CopyToDesktop ran but intermediate {fusion_ext} file not found.",
"_hint": "Ensure the correct workspace is active: PCB Editor / Board Layout for .brd, "
"Schematic Editor for .sch.",
}
# Extract the EAGLE XML from the ZIP container
eagle_data = None
inner_name = None
try:
with zipfile.ZipFile(actual_tmp, "r") as zf:
for name in zf.namelist():
if name.endswith(ext): # Find the .sch or .brd inside
eagle_data = zf.read(name)
inner_name = name
break
except Exception as e:
# Clean up temp
try:
os.remove(actual_tmp)
os.rmdir(tmp_dir)
except Exception:
pass
return {
"success": False,
"error": f"Failed to read {fusion_ext} ZIP container: {e}",
}
# Clean up temp file
try:
os.remove(actual_tmp)
os.rmdir(tmp_dir)
except Exception:
pass
if not eagle_data:
return {
"success": False,
"error": f"No {ext} file found inside {fusion_ext} ZIP container.",
"_hint": "The Fusion container format may have changed. Use fusion_export_source "
"to get the raw .fsch/.fbrd and inspect with a ZIP tool.",
}
# Verify it looks like EAGLE XML
header = eagle_data[:100].decode("utf-8", errors="replace")
if "<eagle" not in header and "<?xml" not in header:
return {
"success": False,
"error": f"Extracted file does not appear to be EAGLE XML. "
f"First 100 bytes: {header!r}",
}
# Write to output path
out_dir = os.path.dirname(output_path)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
with open(output_path, "wb") as f:
f.write(eagle_data)
size_bytes = len(eagle_data)
# Get context
doc_name = ""
workspace = ""
try:
doc = app.activeDocument
if doc:
doc_name = doc.name
workspace = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()
except Exception:
pass
return {
"success": True,
"output": f"Exported EAGLE {ext} ({size_bytes:,} bytes) to {output_path}",
"data": {
"savedTo": output_path.replace("\\", "/"),
"sizeBytes": size_bytes,
"format": ext.lstrip("."),
"innerZipEntry": inner_name,
"activeDocument": doc_name,
"activeWorkspace": workspace,
},
"_hint": "This is a plain EAGLE XML file — parseable by standalone EAGLE, "
"KiCad import, or any XML tool. For Fusion-format source, use "
"fusion_export_source instead.",
}
def handle_execute_text_command(app: adsk.core.Application, args: dict) -> dict:
"""Execute any Fusion 360 text command directly.
This is a lower-level command that runs `app.executeTextCommand()` without
any wrapper. Useful for workspace info, debugging, or non-Electron commands.
Unlike Electron.run, many text commands DO return meaningful results
(e.g., DebugCommands.ActiveWorkspace returns the workspace name).
Args:
args: Dict with:
- command: The full text command to execute (required).
"""
text_cmd = args.get("command", "")
if not text_cmd:
return {"success": False, "error": "No command specified."}
try:
result = app.executeTextCommand(text_cmd)
data = {"command": text_cmd, "result": result.strip() if result else ""}
# Add workspace context for common debug queries
if not text_cmd.startswith("DebugCommands.ActiveWorkspace"):
try:
ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")
data["activeWorkspace"] = ws.strip() if ws else "unknown"
except Exception:
pass
try:
doc = app.activeDocument
if doc:
data["activeDocument"] = doc.name
except Exception:
pass
return {
"success": True,
"output": result.strip() if result else f"Executed: {text_cmd} (no output)",
"data": data,
}
except Exception as e:
return {
"success": False,
"error": f"Text command failed: {e}",
"_hint": "Call fusion_list_text_commands to discover valid commands, or verify the active workspace supports this command.",
}
def handle_list_text_commands(app: adsk.core.Application, args: dict) -> dict:
"""List available Fusion 360 text commands matching a filter.
Args:
args: Dict with:
- filter: Optional filter string (default: "Electron").
"""
filter_str = args.get("filter", "Electron")
try:
result = app.executeTextCommand("TextCommands.List")
if result:
lines = result.split("\n")
matching = [l for l in lines if filter_str.lower() in l.lower()]
return {
"success": True,
"output": f"Found {len(matching)} commands matching '{filter_str}'",
"data": {
"commands": matching[:50],
"total": len(matching),
},
}
return {
"success": True,
"output": "TextCommands.List returned no output",
"data": {"commands": []},
}
except Exception as e:
return {
"success": False,
"error": f"TextCommands.List failed: {e}",
"_hint": "Ensure an Electronics workspace is active — call fusion_open_schematic, fusion_open_board, or fusion_open_lbr first, then retry.",
}
def handle_board_info(app: adsk.core.Application, args: dict) -> dict:
"""Get structured board data from the current PCB layout.
Returns XML with copper traces, pads, component placements (name, x, y,
rotation), net names, layer setup, board thickness, DRC violations, and
library packages used. Only works when a .brd board is open in PCB Editor.
This is much richer than a screenshot — the AI can read exact coordinates,
net connectivity, and component positions.
"""
# Electron.boardInfo requires an output file path
board_info_path = os.path.join(tempfile.gettempdir(), "adom_board_info.xml")
try:
app.executeTextCommand(f"Electron.boardInfo {board_info_path}")
except Exception as e:
err_msg = str(e)
if "not PCB2d document" in err_msg.lower() or "not pcb" in err_msg.lower():
ws = "unknown"
try:
ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()
except Exception:
pass
return {
"success": False,
"error": f"No board open (current workspace: {ws}). Open a .brd file first with fusion_open_board.",
"_hint": "Call fusion_open_board {\"filePath\":\"...\"} or fusion_show_2d_board to enter Board Layout, then retry.",
"data": {"activeWorkspace": ws},
}
return {
"success": False,
"error": f"boardInfo failed: {e}",
"_hint": "Ensure a .brd file is open in Board Layout — call fusion_show_2d_board first, then retry.",
}
# Read the output file (Fusion writes UTF-16 LE BOM)
result = None
for enc in ("utf-16", "utf-8-sig", "utf-8"):
try:
with open(board_info_path, "r", encoding=enc) as f:
result = f.read()
break
except (UnicodeDecodeError, UnicodeError):
continue
if result is None:
try:
with open(board_info_path, "rb") as f:
raw = f.read()
return {"success": False, "error": f"Failed to decode boardInfo (first bytes: {raw[:20].hex()})"}
except Exception as e:
return {"success": False, "error": f"Failed to read boardInfo output: {e}"}
if not result:
return {"success": False, "error": "boardInfo returned no data. Is a .brd board open?"}
# Parse the XML to extract a summary for the AI
import xml.etree.ElementTree as ET
summary = {}
try:
root = ET.fromstring(result)
board = root.find("g")
if board is not None:
summary["name"] = board.get("name", "")
summary["unit"] = board.get("unit", "mm")
summary["boardThickness"] = board.get("boardThickness", "")
summary["layerSetup"] = board.get("layersetup", "")
# Extract component placements from <libraries>
components = []
for lib_group in root.iter("g"):
lib = lib_group.get("library")
pkg = lib_group.get("package")
if lib and pkg:
for comp in lib_group.findall("g"):
name = comp.get("name")
if name:
components.append({
"name": name,
"library": lib,
"package": pkg,
"x": comp.get("x"),
"y": comp.get("y"),
"rotation": comp.get("rot", "R0"),
"locked": comp.get("LOCKED", "false") == "true",
})
summary["components"] = components
summary["componentCount"] = len(components)
# Extract net names from copper traces
nets = set()
for copper in root.iter("copper"):
net_name = copper.get("name", "")
if net_name:
nets.add(net_name)
summary["nets"] = sorted(nets)
summary["netCount"] = len(nets)
# Extract DRC issues
drc = root.find(".//drc")
if drc is not None:
overlaps = []
for overlap in drc.findall(".//overlap"):
for signal in overlap.findall("signal"):
overlaps.append(signal.get("name", ""))
if overlaps:
summary["drcOverlaps"] = overlaps
except ET.ParseError:
# If XML parsing fails, just return the raw result
pass
return {
"success": True,
"output": f"Board info: {summary.get('name', 'unknown')}, "
f"{summary.get('componentCount', 0)} components, "
f"{summary.get('netCount', 0)} nets",
"data": {
"summary": summary,
"rawXml": result, # Full XML for detailed analysis if needed
},
}