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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886
"""Cloud document management for Fusion 360.
Provides commands to save documents to Autodesk's cloud hub,
list cloud projects and files, and delete cloud files.
Uses the Fusion Data API (app.data) which requires the user
to be signed in to their Autodesk account.
Key concepts:
- Hub: The team/organization account (e.g., "Adom")
- Project: A folder-like container in the hub (e.g., "Personal", "Main")
- DataFolder: Subfolder within a project
- DataFile: A cloud-stored document with URN, version history, etc.
- wip_urn: The version-specific URN used by Electronics libraries
to reference 3D package models (e.g., urn:adsk.wipprod:fs.file:vf.xxx?version=1)
"""
import adsk.core
import adsk.fusion
import os
import threading
import time
# Walk/search progress state — updated by BFS loop on main thread,
# read by health endpoint on HTTP thread.
_walk_progress_lock = threading.Lock()
_walk_progress = None # None when no walk/search is active
def get_walk_progress():
"""Return a snapshot of the current walk/search progress, or None."""
with _walk_progress_lock:
if _walk_progress is None:
return None
return dict(_walk_progress)
RESOLVE_PROJECT_TIMEOUT = 60 # seconds — overall wall-clock for hub/project enumeration
def _resolve_project(app, project_name=None):
"""Find a cloud project by name. Defaults to active project.
Robustness:
- Every per-hub / per-project access wrapped in try/except so a single
bad hub or project can't crash the resolve.
- adsk.doEvents() yields between hubs so the UI stays responsive.
- Overall wall-clock cap (RESOLVE_PROJECT_TIMEOUT). On expired/trial
subscriptions the cloud Data API can take 30s+ per hub; cap the
total so a hung lookup returns an actionable error instead of
hanging Fusion's main thread indefinitely.
"""
data = app.data
if not data:
return None, "Fusion Data API not available. Are you signed in?"
if not project_name:
try:
proj = data.activeProject
except Exception as e:
return None, f"activeProject lookup failed: {e}"
if proj:
return proj, None
return None, "No active project"
needle = project_name.lower()
started = time.time()
# Search all hubs for the named project. Each hub.dataProjects access
# is a network round-trip on the cloud Data API; wrap in try/except so
# one bad hub doesn't take down the whole search.
try:
hubs = data.dataHubs
hub_count = hubs.count
except Exception as e:
return None, f"Failed to enumerate hubs: {e}"
for i in range(hub_count):
if time.time() - started > RESOLVE_PROJECT_TIMEOUT:
try:
adsk.core.Application.log(
f"AdomBridge: _resolve_project hit timeout ({RESOLVE_PROJECT_TIMEOUT}s) "
f"at hub {i}/{hub_count} searching for '{project_name}'"
)
except Exception:
pass
return None, (
f"Project lookup timed out after {RESOLVE_PROJECT_TIMEOUT}s "
f"(searched {i}/{hub_count} hubs). Cloud API may be slow or "
f"unavailable."
)
try:
hub = hubs.item(i)
except Exception:
continue
try:
projects = hub.dataProjects
proj_count = projects.count
except Exception:
continue
for j in range(proj_count):
try:
proj = projects.item(j)
if proj.name.lower() == needle:
return proj, None
except Exception:
continue
# Yield between hubs so the UI stays responsive even when the
# team has many hubs / projects.
try:
adsk.doEvents()
except Exception:
pass
return None, f"Project '{project_name}' not found"
def _resolve_folder(project, folder_path=None):
"""Navigate to a subfolder within a project. Returns root folder if no path given.
Robustness: each `dataFolders.item(i).name` access is wrapped in
try/except so a single corrupted subfolder doesn't crash navigation.
"""
try:
folder = project.rootFolder
except Exception as e:
return None, f"rootFolder lookup failed: {e}"
if not folder_path:
return folder, None
parts = [p.strip() for p in folder_path.replace("\\", "/").split("/") if p.strip()]
for part in parts:
needle = part.lower()
found = False
try:
subfolders = folder.dataFolders
sub_count = subfolders.count
except Exception as e:
return None, f"dataFolders lookup failed for '{folder.name}': {e}"
for i in range(sub_count):
try:
sf = subfolders.item(i)
if sf.name.lower() == needle:
folder = sf
found = True
break
except Exception:
continue
if not found:
return None, f"Folder '{part}' not found in '{folder.name}'"
return folder, None
def handle_save_to_cloud(app: adsk.core.Application, args: dict) -> dict:
"""Save the active Fusion document to the cloud.
Args:
args: Dict with:
- name: Name for the cloud document (required).
- projectName: Target project name (optional, defaults to active project).
- folderPath: Subfolder path within the project (optional, defaults to root).
- description: Document description (optional).
"""
name = args.get("name", "").strip()
if not name:
return {"success": False, "error": "No name specified. Provide a name for the cloud document."}
doc = app.activeDocument
if not doc:
return {
"success": False,
"error": "No active document to save.",
"_hint": "Call fusion_open_cloud_file or fusion_import_file first to have an active document, then retry.",
}
project_name = args.get("projectName", "")
folder_path = args.get("folderPath", "")
description = args.get("description", "")
project, err = _resolve_project(app, project_name or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see available project names, then retry with a valid projectName.",
}
folder, err = _resolve_folder(project, folder_path or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_files with this projectName to see valid folderPath values, or call fusion_create_cloud_folder to create it.",
}
try:
result = doc.saveAs(name, folder, description, "")
except Exception as e:
return {
"success": False,
"error": f"saveAs failed: {e}",
"data": {"hint": "The document may already exist with this name, or you may lack permissions."},
}
if not result:
return {"success": False, "error": "saveAs returned False. The save may have failed."}
# Collect saved file info
response_data = {
"name": name,
"project": project.name,
"folder": folder.name,
"documentName": doc.name,
"isSaved": doc.isSaved,
}
df = doc.dataFile
if df:
response_data["fileId"] = df.id
try:
response_data["versionId"] = df.versionId
except Exception:
pass
try:
response_data["versionNumber"] = df.versionNumber
except Exception:
pass
# The wip_urn format used by Electronics libraries for 3D packages
if df.id and "wipprod" in df.id:
response_data["wipUrn"] = df.id
return {
"success": True,
"output": f"Saved '{name}' to project '{project.name}'",
"data": response_data,
}
def handle_list_cloud_projects(app: adsk.core.Application, args: dict) -> dict:
"""List all cloud projects in the user's hub(s).
Robustness (v1.0.2):
- Per-hub / per-project try/except so a single bad item doesn't kill
the whole listing.
- adsk.doEvents() between hubs to keep the UI responsive.
- Overall wall-clock cap of LIST_PROJECTS_TIMEOUT seconds.
- Progress logged via Application.log so a slow listing is visible.
Args:
args: Dict (no required args).
"""
data = app.data
if not data:
return {
"success": False,
"error": "Fusion Data API not available. Are you signed in?",
"_hint": "Report to the user and ask them to sign into their Autodesk account in Fusion 360, then retry.",
}
LIST_PROJECTS_TIMEOUT = 90 # seconds — overall wall-clock cap
started = time.time()
list_timed_out = False
hubs_data = []
try:
hubs = data.dataHubs
hub_count = hubs.count
except Exception as e:
return {
"success": False,
"error": f"Failed to enumerate hubs: {e}",
"_hint": "Cloud Data API may be unavailable. Verify sign-in status in Fusion (Help → About / sign-in icon).",
}
for i in range(hub_count):
if time.time() - started > LIST_PROJECTS_TIMEOUT:
list_timed_out = True
try:
adsk.core.Application.log(
f"AdomBridge: list_cloud_projects timed out at hub {i}/{hub_count}"
)
except Exception:
pass
break
try:
hub = hubs.item(i)
hub_name = hub.name
hub_id = hub.id
except Exception:
continue
try:
adsk.core.Application.log(
f"AdomBridge: list_cloud_projects hub [{i+1}/{hub_count}] '{hub_name}'"
)
except Exception:
pass
projects_list = []
try:
projects = hub.dataProjects
proj_count = projects.count
except Exception:
# Couldn't enumerate this hub's projects — still report the hub
hubs_data.append({"name": hub_name, "id": hub_id, "projects": [], "_error": "dataProjects enumeration failed"})
continue
for j in range(proj_count):
try:
proj = projects.item(j)
proj_info = {
"name": proj.name,
"id": proj.id,
}
except Exception:
continue
try:
rf = proj.rootFolder
if rf:
proj_info["rootFolderId"] = rf.id
except Exception:
pass
projects_list.append(proj_info)
hubs_data.append({
"name": hub_name,
"id": hub_id,
"projects": projects_list,
})
# Yield between hubs
try:
adsk.doEvents()
except Exception:
pass
# Also note the active project (safely)
try:
active_proj = data.activeProject
active_name = active_proj.name if active_proj else "none"
except Exception:
active_name = "(lookup failed)"
total = sum(len(h["projects"]) for h in hubs_data)
elapsed = round(time.time() - started, 1)
return {
"success": True,
"output": f"Found {total} projects across {len(hubs_data)} hub(s). Active: {active_name}"
f"{' (listing timed out, partial)' if list_timed_out else ''}",
"data": {
"activeProject": active_name,
"hubs": hubs_data,
"elapsedSeconds": elapsed,
"listTimedOut": list_timed_out,
},
"_hint": "Use fusion_list_cloud_files with a projectName from this list to browse folders. "
"Use fusion_walk_cloud_tree for deep recursive search with filters.",
}
LIST_FILES_TIMEOUT = 60 # seconds — overall wall-clock cap
def handle_list_cloud_files(app: adsk.core.Application, args: dict) -> dict:
"""List files in a cloud project/folder.
Robustness (v1.0.2):
- Per-file / per-subfolder try/except so a single corrupted item
doesn't kill the whole listing.
- adsk.doEvents() every 50 files to keep Fusion's UI responsive on
folders containing thousands of files.
- Overall wall-clock cap (LIST_FILES_TIMEOUT). Returns partial
results with listTimedOut=true instead of hanging.
Args:
args: Dict with:
- projectName: Project name (optional, defaults to active project).
- folderPath: Subfolder path (optional, defaults to root folder).
"""
project_name = args.get("projectName", "")
folder_path = args.get("folderPath", "")
project, err = _resolve_project(app, project_name or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see valid project names.",
}
folder, err = _resolve_folder(project, folder_path or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_files with this projectName to see valid folderPath values.",
}
started = time.time()
list_timed_out = False
files_skipped = 0
files_list = []
try:
try:
adsk.core.Application.log(
f"AdomBridge: list_cloud_files '{folder_path or '(root)'}' in '{project.name}'"
)
except Exception:
pass
try:
data_files = folder.dataFiles
file_count = data_files.count
except Exception as e:
return {
"success": False,
"error": f"Failed to enumerate files: {e}",
"_hint": "Call fusion_get_app_state to verify sign-in status; Autodesk cloud session may have expired. If so, the user must sign in via the Fusion UI.",
}
for i in range(file_count):
# Yield every 50 files
if i > 0 and i % 50 == 0:
try:
adsk.doEvents()
except Exception:
pass
# Wall-clock cap
if time.time() - started > LIST_FILES_TIMEOUT:
list_timed_out = True
try:
adsk.core.Application.log(
f"AdomBridge: list_cloud_files hit timeout at file {i}/{file_count}"
)
except Exception:
pass
break
# Per-item try/except — a single corrupt/deleting file shouldn't
# break the whole listing.
try:
df = data_files.item(i)
except Exception:
files_skipped += 1
continue
try:
file_info = {"name": df.name}
except Exception:
files_skipped += 1
continue
try:
file_info["id"] = df.id
except Exception:
pass
try:
file_info["versionNumber"] = df.versionNumber
except Exception:
pass
try:
file_info["versionId"] = df.versionId
except Exception:
pass
try:
file_info["fileExtension"] = df.fileExtension
except Exception:
pass
try:
file_info["dateModified"] = str(df.dateModified)
except Exception:
pass
files_list.append(file_info)
except Exception as e:
return {
"success": False,
"error": f"Failed to list files: {e}",
"_hint": "Call fusion_get_app_state to verify sign-in status; Autodesk cloud session may have expired. If so, the user must sign in via the Fusion UI.",
}
# Also list subfolders (per-item resilient)
subfolders = []
folders_skipped = 0
try:
folders = folder.dataFolders
for i in range(folders.count):
if time.time() - started > LIST_FILES_TIMEOUT:
list_timed_out = True
break
try:
sf = folders.item(i)
subfolders.append({"name": sf.name, "id": sf.id})
except Exception:
folders_skipped += 1
continue
except Exception:
pass
return {
"success": True,
"output": f"Found {len(files_list)} files and {len(subfolders)} folders in '{folder.name}' (project: {project.name})"
f"{' [listing timed out, partial]' if list_timed_out else ''}",
"data": {
"project": project.name,
"folder": folder.name,
"files": files_list,
"subfolders": subfolders,
"elapsedSeconds": round(time.time() - started, 1),
"filesSkipped": files_skipped,
"foldersSkipped": folders_skipped,
"listTimedOut": list_timed_out,
},
}
def handle_delete_cloud_file(app: adsk.core.Application, args: dict) -> dict:
"""Delete a file from the cloud by name.
Searches the specified project/folder for the file and deletes it.
Args:
args: Dict with:
- fileName: Name of the file to delete (required).
- projectName: Project name (optional, defaults to active project).
- folderPath: Subfolder path (optional, defaults to root folder).
"""
file_name = args.get("fileName", "").strip()
file_id = (args.get("fileId") or args.get("lineageUrn") or "").strip() # PRECISE delete by id
if not file_name and not file_id:
return {"success": False, "error": "Provide fileName or fileId (lineage urn for a precise delete)."}
project_name = args.get("projectName", "")
folder_path = args.get("folderPath", "")
project, err = _resolve_project(app, project_name or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see valid project names.",
}
folder, err = _resolve_folder(project, folder_path or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_files with this projectName to see valid folderPath values.",
}
# Find the file (per-item resilient, with yield + timeout)
target = None
started = time.time()
needle = file_name.lower()
id_needle = file_id # exact lineage-urn match when provided (safe in shared folders)
try:
try:
data_files = folder.dataFiles
file_count = data_files.count
except Exception as e:
return {"success": False, "error": f"Failed to enumerate files in '{folder.name}': {e}"}
for i in range(file_count):
if i > 0 and i % 50 == 0:
try:
adsk.doEvents()
except Exception:
pass
if time.time() - started > FIND_FILE_TIMEOUT:
return {
"success": False,
"error": f"File lookup timed out after {FIND_FILE_TIMEOUT}s "
f"(scanned {i}/{file_count} files in '{folder.name}')",
"_hint": "Folder may be too large; provide a more specific folderPath, "
"or use fusion_walk_cloud_tree with maxFolders + nameContains.",
}
try:
df = data_files.item(i)
if id_needle:
if df.id == id_needle:
target = df
break
elif df.name.lower() == needle:
target = df
break
except Exception:
continue
except Exception as e:
return {"success": False, "error": f"Failed to search files: {e}"}
if not target:
return {
"success": False,
"error": f"File '{file_name}' not found in '{folder.name}' (project: {project.name})",
"_hint": "Call fusion_walk_cloud_tree to locate the file, or fusion_list_cloud_files to list what's in the folder.",
}
file_id = target.id
try:
target.deleteMe()
except Exception as e:
return {
"success": False,
"error": f"Delete failed: {e}",
"data": {"hint": "You may lack permissions to delete this file."},
}
return {
"success": True,
"output": f"Deleted '{file_name}' from '{folder.name}' (project: {project.name})",
"data": {
"fileName": file_name,
"fileId": file_id,
"project": project.name,
"folder": folder.name,
},
}
def handle_create_cloud_folder(app: adsk.core.Application, args: dict) -> dict:
"""Create a new folder in a cloud project.
Args:
args: Dict with:
- folderName: Name of the folder to create (required).
- projectName: Target project (optional, defaults to active project).
- parentPath: Parent folder path (optional, defaults to project root).
"""
folder_name = args.get("folderName", "").strip()
if not folder_name:
return {"success": False, "error": "No folderName specified."}
project_name = args.get("projectName", "")
parent_path = args.get("parentPath", "")
project, err = _resolve_project(app, project_name or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see available projects, or fusion_list_cloud_files with a known project name to browse folders.",
}
parent, err = _resolve_folder(project, parent_path or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see available projects, or fusion_list_cloud_files with a known project name to browse folders.",
}
# Check if folder already exists (per-item resilient, with yield + timeout)
needle = folder_name.lower()
started = time.time()
try:
existing = parent.dataFolders
existing_count = existing.count
except Exception as e:
return {
"success": False,
"error": f"Failed to enumerate subfolders of '{parent.name}': {e}",
"_hint": "Cloud Data API may be unavailable. Verify sign-in status in Fusion.",
}
for i in range(existing_count):
if i > 0 and i % 50 == 0:
try:
adsk.doEvents()
except Exception:
pass
if time.time() - started > 30:
# Don't hang the main thread enumerating a huge subfolder list.
# If we didn't find a match in 30s, fall through to create — Fusion
# itself will detect a collision on add().
break
try:
sf = existing.item(i)
if sf.name.lower() == needle:
return {
"success": True,
"output": f"Folder '{folder_name}' already exists in '{parent.name}'",
"data": {
"folderName": sf.name,
"folderId": sf.id,
"project": project.name,
"parentFolder": parent.name,
"alreadyExisted": True,
},
}
except Exception:
continue
try:
new_folder = parent.dataFolders.add(folder_name)
except Exception as e:
return {
"success": False,
"error": f"Failed to create folder: {e}",
"_hint": "Call fusion_get_app_state to verify sign-in status; Autodesk cloud session may have expired. If so, the user must sign in via the Fusion UI.",
}
return {
"success": True,
"output": f"Created folder '{folder_name}' in '{parent.name}' (project: {project.name})",
"data": {
"folderName": new_folder.name,
"folderId": new_folder.id,
"project": project.name,
"parentFolder": parent.name,
},
}
FIND_FILE_TIMEOUT = 60 # seconds — overall wall-clock cap for find
def _find_cloud_file(app, args):
"""Find a cloud file by name in a project/folder. Returns (dataFile, project, folder, error).
Robustness (v1.0.2): per-file try/except + doEvents every 50 files + wall-clock cap.
"""
file_name = args.get("fileName", "").strip()
if not file_name:
return None, None, None, "No fileName specified."
project_name = args.get("projectName", "")
folder_path = args.get("folderPath", "")
project, err = _resolve_project(app, project_name or None)
if err:
return None, None, None, err
folder, err = _resolve_folder(project, folder_path or None)
if err:
return None, None, None, err
file_ext = args.get("fileExtension", "").strip().lower().lstrip(".")
needle = file_name.lower()
started = time.time()
target = None
try:
try:
data_files = folder.dataFiles
file_count = data_files.count
except Exception as e:
return None, None, None, f"Failed to enumerate files in '{folder.name}': {e}"
for i in range(file_count):
# Yield every 50 files so Fusion UI stays responsive
if i > 0 and i % 50 == 0:
try:
adsk.doEvents()
except Exception:
pass
# Wall-clock cap
if time.time() - started > FIND_FILE_TIMEOUT:
return None, None, None, (
f"File lookup timed out after {FIND_FILE_TIMEOUT}s "
f"(scanned {i}/{file_count} files in '{folder.name}'). "
f"Folder may be too large; try a more specific folderPath."
)
# Per-item try/except
try:
df = data_files.item(i)
df_name = df.name
except Exception:
continue
if df_name.lower() == needle:
# If extension filter specified, match it
if file_ext:
try:
if df.fileExtension.lower().lstrip(".") != file_ext:
continue
except Exception:
continue
target = df
break
except Exception as e:
return None, None, None, f"Failed to search files: {e}"
if not target:
ext_hint = f" with extension '.{file_ext}'" if file_ext else ""
return None, None, None, f"File '{file_name}'{ext_hint} not found in '{folder.name}' (project: {project.name})"
return target, project, folder, None
def handle_check_recovery(app: adsk.core.Application, args: dict) -> dict:
"""Check if a cloud file has a recovery document from a previous crash.
NOTE: The Fusion Python API does not reliably expose recovery document
status. This command attempts to check, but may return false negatives.
The most reliable detection is at the bridge level — if open_cloud_file
times out, it's likely because a recovery dialog appeared.
Args:
fileName: Name of the file to check (required).
projectName: Project to search in (optional, defaults to active project).
folderPath: Subfolder path (optional, defaults to root).
"""
target, project, folder, err = _find_cloud_file(app, args)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see available projects, or fusion_list_cloud_files with a known project name to browse folders.",
}
# Try to check for recovery doc — but these API methods may not exist
has_recovery = False
try:
if hasattr(target, 'hasRecoveryDocument'):
has_recovery = target.hasRecoveryDocument
except Exception:
pass
return {
"success": True,
"output": (
f"Recovery document found for '{target.name}' (API check)."
if has_recovery
else f"No recovery document detected for '{target.name}' via API. "
"Note: this check is not 100% reliable — Fusion may still show "
"a recovery dialog when opening. The bridge will detect this "
"via timeout if it happens."
),
"data": {
"fileName": target.name,
"fileId": target.id,
"project": project.name,
"hasRecovery": has_recovery,
"apiReliable": hasattr(target, 'hasRecoveryDocument'),
},
}
def _ecad_open_reprimand(app):
"""Detect when an AI opened an electronics CHILD file instead of the PROJECT.
Electronics files are a parent/child chain: PROJECT (EcadDesignProductType) ->
schematic (SchematicProductType) -> board/.brd (BoardProductType) -> 3D (the leaf,
generated from the .brd). Only the PROJECT should be opened directly; its schematic and
board open from it. Returns a reprimand string for a child file, a confirmation for the
project, or '' if it cannot tell.
"""
try:
pt = app.activeProduct.productType
except Exception:
return ''
if pt == 'EcadDesignProductType':
return ('OK: opened the electronics PROJECT file (EcadDesignProductType) - correct. '
'Its schematic, board and 3D are children; switch to them from here.')
nav = (' Electronics files are a parent/child chain: PROJECT (EcadDesignProductType) ->'
' schematic (SchematicProductType) -> board/.brd (BoardProductType) -> 3D (the leaf,'
' generated from the .brd). ALWAYS open the PROJECT file; its schematic and board open'
' from it. NEVER open a schematic, .brd, or 3D file directly to get the board.')
label = {'SchematicProductType': 'the SCHEMATIC',
'BoardProductType': 'the BOARD (.brd) / its 3D PCB',
'DesignProductType': 'a 3D model file'}.get(pt)
if label:
return ('STOP - WRONG FILE: you opened ' + label + ' (' + pt + ') directly, not the project.'
+ nav)
return ''
def handle_open_cloud_file(app: adsk.core.Application, args: dict) -> dict:
"""Open a cloud file in Fusion 360 by name.
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.
NOTE: If the file has a recovery document from a previous crash, Fusion
will show a blocking modal dialog. The bridge layer (server.py) detects
this via timeout and reports it to the AI. The AI should then screenshot
and decide how to handle the dialog.
Args:
fileName: Name of the file to open (required).
projectName: Project to search in (optional, defaults to active project).
folderPath: Subfolder path (optional, defaults to root).
"""
target, project, folder, err = _find_cloud_file(app, args)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see available projects, or fusion_list_cloud_files with a known project name to browse folders.",
}
# Open the file — if a recovery dialog appears, this call will block
# and the bridge will detect the timeout.
# Retry on InternalValidationError: Fusion's cloud hub frequently
# throws this on the first try after a recent close, but the second
# attempt (with a brief delay) usually succeeds.
import time
doc = None
last_err = None
attempts = []
for attempt in range(3):
try:
doc = app.documents.open(target)
if doc:
break
except Exception as e:
last_err = str(e)
attempts.append(f"attempt {attempt+1}: {last_err}")
if "InternalValidationError" in last_err and attempt < 2:
time.sleep(2 + attempt * 2) # 2s, 4s
continue
break
if not doc:
return {
"success": False,
"error": f"Failed to open '{target.name}' after {len(attempts)} attempts: {last_err}",
"_hint": "Call fusion_screenshot to check for a recovery dialog or other modal, then fusion_send_key with {\"key\":\"escape\"} or click to dismiss it, then retry.",
"data": {"fileId": target.id, "attempts": attempts},
}
# Explicitly activate the freshly-opened doc. Fusion's cloud hub can
# auto-reopen recent tabs in the background, stealing focus from the
# doc we just opened. Force it back.
activate_note = None
try:
doc.activate()
activate_note = "activated"
except Exception as e:
activate_note = f"activate-failed: {e}"
# Enumerate ALL currently open docs so the caller can see any ghost tabs
# that Fusion auto-reopened alongside the target.
open_docs = []
try:
for i in range(app.documents.count):
d = app.documents.item(i)
df_name = None
try:
df_name = d.dataFile.name if d.dataFile else None
except Exception:
df_name = None
open_docs.append({
"name": d.name,
"dataFileName": df_name,
"isActive": (d == app.activeDocument),
})
except Exception:
pass
# Check whether the now-active doc actually matches the target
active_matches = False
try:
active_df_name = app.activeDocument.dataFile.name if app.activeDocument and app.activeDocument.dataFile else None
active_matches = (active_df_name == target.name)
except Exception:
pass
response_data = {
"fileName": target.name,
"fileId": target.id,
"project": project.name,
"documentName": doc.name,
"activateResult": activate_note,
"activeMatchesTarget": active_matches,
"openDocuments": open_docs,
"ghostTabCount": max(0, len(open_docs) - 1),
}
try:
response_data["versionNumber"] = target.versionNumber
except Exception:
pass
try:
response_data["fileExtension"] = target.fileExtension
except Exception:
pass
try:
ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()
response_data["activeWorkspace"] = ws
except Exception:
pass
_rep = _ecad_open_reprimand(app)
return {
"success": True,
"output": ((_rep + " || ") if _rep.startswith("STOP") else "") + f"Opened '{target.name}' from project '{project.name}'",
"_hint": _rep,
"message": f"Opened '{target.name}'. IMPORTANT: Fusion may show a blocking dialog "
f"('What to design?', 'PCB out of date', etc.) that is NOT visible to the API. "
f"You MUST screenshot the Fusion window now to verify no dialog is blocking.",
"data": response_data,
"postOpenCheck": {
"required": True,
"steps": [
"Run desktop_list_windows to find the Fusion HWND",
"Run desktop_screenshot_window with that HWND",
"If a dialog is visible, dismiss it with fusion_send_key {\"key\": \"escape\"} or fusion_click_fusion on the Cancel/X button",
"Screenshot again to confirm the dialog is gone",
],
"commonDialogs": [
{"name": "What to design?", "dismiss": "fusion_send_key {\"key\": \"escape\"} or click Cancel"},
{"name": "PCB out of date", "dismiss": "Click Update or X to dismiss"},
{"name": "Recovered Documents", "dismiss": "fusion_dismiss_recovery"},
{"name": "Save changes?", "dismiss": "fusion_send_key {\"key\": \"escape\"} to cancel"},
],
},
}
def handle_search_cloud_files(app: adsk.core.Application, args: dict) -> dict:
"""Search for files in a cloud project by name.
[WARNING - DO NOT USE FOR SEARCH] This goes through Fusion's rate-limited
add-in Data API. It is SLOW (a real project can take 30+ MINUTES) and CAN
CRASH the live Fusion session: the add-in connection gets forcibly closed
(WinError 10054) and Fusion goes down. Use fusion_aps_search instead (APS
Data Management server-side index - returns in SECONDS). Read the
'fusion-aps-search' skill before touching this verb. Only fall back here as
a last resort when APS is unconfigured AND you accept the crash risk.
Case-insensitive substring match on file names. Searches the root folder
by default (fast, safe). Set recursive=true to search subfolders — but
this makes one cloud API call per folder, so use maxDepth and maxFolders
to stay safe.
SAFETY (v1.0.1 — May 2026): The previous implementation had thin
defenses and could freeze Fusion's main thread for long enough that
Windows marked the window "Not Responding" — users perceived this as
a crash. The fixes:
- Per-folder wall-clock timeout (FOLDER_TIMEOUT = 30s). If a single
folder's cloud calls take >30s we skip the rest of that folder
and move on, instead of blocking the main thread indefinitely.
- Overall search wall-clock cap (SEARCH_TIMEOUT = 120s). The whole
search aborts cleanly with partial results if exceeded.
- Per-file try/except wrapping `df.name` and `df.fileExtension`
access. A single corrupt file no longer kills the folder.
- adsk.doEvents() yields every 50 files (not just per-folder), so
a folder with thousands of files doesn't starve the UI.
- adsk.core.Application.log() per-folder progress so post-mortem
forensics show exactly where the search was at any point.
- Iterative BFS queue (no recursion). Prevents Python stack growth
on deep trees.
Args:
args: Dict with:
- query: Search string (required). Matches file names.
- projectName: Limit search to this project (optional, defaults to active).
- folderPath: Start search in this subfolder (optional, defaults to root).
- recursive: Search subfolders (default: false — root only).
- maxDepth: Max recursion depth (default: 2, no upper cap).
- maxFolders: Max folders to visit (default: 10, no upper cap).
Default is intentionally low — search_cloud_files is
tuned for quick targeted lookups. For exhaustive walks
across hundreds of folders, prefer fusion_walk_cloud_tree
which has a 600s envelope vs search's 120s.
- maxResults: Max results to return (default: 20, no upper cap).
- folderTimeout: Per-folder cloud-API timeout in seconds (default: 30).
- searchTimeout: Overall wall-clock timeout in seconds (default: 120).
If you raise this above 180, also pass `timeout:<N>`
at the bridge level so the HTTP waiter envelope
doesn't give up first.
Returns data:
files, totalFound, foldersSearched, foldersSkipped, project, truncated,
folderLimitReached, searchTimedOut, elapsedSeconds, hint.
"""
query = args.get("query", "").strip().lower()
if not query:
return {"success": False, "error": "No query specified."}
project_name = args.get("projectName", "")
folder_path = args.get("folderPath", "")
recursive = bool(args.get("recursive", False))
try:
# No hard upper caps in v1.0.2+ — the per-folder timeout, doEvents
# yield, per-file try/except, overall search timeout, and stale-lock
# watchdog all make hard caps unnecessary. You can ask for an
# exhaustive search of the entire cloud (e.g. maxFolders=10000); the
# overall timeout governs when partial results are returned.
max_depth = max(0, int(args.get("maxDepth", 2)))
max_folders = max(1, int(args.get("maxFolders", 10)))
max_results = max(1, int(args.get("maxResults", 20)))
except (TypeError, ValueError):
return {
"success": False,
"error": "maxDepth / maxFolders / maxResults must be integers.",
}
# Bounds on cloud-API time. The per-folder timeout protects against a
# single slow folder. The overall search timeout is the escape hatch.
# Both are tunable per-call via folderTimeout / searchTimeout args.
try:
FOLDER_TIMEOUT = max(5, int(args.get("folderTimeout", 30)))
SEARCH_TIMEOUT = max(10, int(args.get("searchTimeout", 120)))
except (TypeError, ValueError):
FOLDER_TIMEOUT = 30
SEARCH_TIMEOUT = 120
# NOTE: the HTTP-server per-command timeout (180s by default for
# search_cloud_files in http_server.PER_COMMAND_TIMEOUT) is the OUTER
# envelope. If you set searchTimeout > 180, also pass `timeout`:<N>
# at the bridge level so the HTTP waiter doesn't give up first. For
# truly exhaustive walks of huge trees (10+ minutes), prefer
# fusion_walk_cloud_tree (600s envelope, default maxFolders=500).
data = app.data
if not data:
return {"success": False, "error": "Fusion Data API not available."}
# Resolve starting point
if project_name:
project, err = _resolve_project(app, project_name)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see valid project names.",
}
else:
try:
project = data.activeProject
except Exception as e:
return {"success": False, "error": f"activeProject lookup failed: {e}"}
if not project:
return {"success": False, "error": "No active project. Specify projectName."}
start_folder, err = _resolve_folder(project, folder_path or None)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_files with this projectName to see valid folderPath values.",
}
results = []
folders_visited = 0
folders_skipped = 0
files_skipped = 0
search_timed_out = False
# Set progress state for external observers
global _walk_progress
search_start = time.time()
with _walk_progress_lock:
_walk_progress = {
"command": "search_cloud_files",
"startedAt": search_start,
"project": project.name,
"query": query,
"foldersVisited": 0,
"currentFolder": folder_path or "(root)",
"filesFound": 0,
"elapsedSeconds": 0,
}
# Iterative BFS queue: list of (folder, path, depth). No recursion =
# bounded Python stack depth + easy to short-circuit on timeout.
start_path = folder_path or ""
queue = [(start_folder, start_path, 0)]
try:
while queue:
# Overall search timeout — return partial results cleanly.
if time.time() - search_start > SEARCH_TIMEOUT:
search_timed_out = True
try:
adsk.core.Application.log(
f"AdomBridge: search hit overall timeout ({SEARCH_TIMEOUT}s) "
f"after {folders_visited} folder(s), {len(results)} result(s)"
)
except Exception:
pass
break
# Folder / result caps
if folders_visited >= max_folders or len(results) >= max_results:
break
folder, path, depth = queue.pop(0)
folders_visited += 1
folder_start = time.time()
try:
adsk.core.Application.log(
f"AdomBridge: search [{folders_visited}/{folders_visited + len(queue)}] '{path or '(root)'}' (depth={depth})"
)
except Exception:
pass
# ── Search files in this folder ──
try:
data_files = folder.dataFiles
# If even getting the dataFiles handle took >FOLDER_TIMEOUT,
# skip iteration entirely.
if time.time() - folder_start > FOLDER_TIMEOUT:
folders_skipped += 1
try:
adsk.core.Application.log(
f"AdomBridge: search skipping slow folder '{path}' (>{FOLDER_TIMEOUT}s on dataFiles)"
)
except Exception:
pass
else:
file_count = data_files.count
for i in range(file_count):
# Yield every 50 files to keep Fusion responsive.
# Without this, a folder with 1000 files would
# block the main thread for the whole iteration.
if i > 0 and i % 50 == 0:
try:
adsk.doEvents()
except Exception:
pass
# Per-file timeout check (cheap).
if time.time() - folder_start > FOLDER_TIMEOUT:
folders_skipped += 1
try:
adsk.core.Application.log(
f"AdomBridge: search folder '{path}' hit per-folder timeout at file {i}"
)
except Exception:
pass
break
if len(results) >= max_results:
break
# Per-file try/except — a single corrupt or
# in-flight-deletion file shouldn't kill the
# whole folder. Just skip it and keep going.
# We COUNT every skip so the caller knows "0 results"
# could be a false negative if files_skipped > 0.
try:
df = data_files.item(i)
except Exception:
files_skipped += 1
continue
try:
df_name = df.name
except Exception:
files_skipped += 1
continue
if query in df_name.lower():
info = {
"name": df_name,
"project": project.name,
"folderPath": path or "/",
}
try:
info["id"] = df.id
except Exception:
pass
try:
info["versionNumber"] = df.versionNumber
except Exception:
pass
try:
info["fileExtension"] = df.fileExtension
except Exception:
pass
results.append(info)
except Exception as e:
folders_skipped += 1
try:
adsk.core.Application.log(
f"AdomBridge: search dataFiles error in '{path}': {e}"
)
except Exception:
pass
# Update progress for external observers (health endpoint, bridge)
with _walk_progress_lock:
if _walk_progress is not None:
_walk_progress["foldersVisited"] = folders_visited
_walk_progress["currentFolder"] = path
_walk_progress["filesFound"] = len(results)
_walk_progress["elapsedSeconds"] = round(time.time() - search_start, 1)
_walk_progress["queueSize"] = len(queue)
# Yield to Fusion's event loop between folders.
try:
adsk.doEvents()
except Exception:
pass
# ── Enqueue subfolders ──
if recursive and depth < max_depth and (time.time() - folder_start <= FOLDER_TIMEOUT):
try:
subfolders = folder.dataFolders
sub_count = subfolders.count
for i in range(sub_count):
if len(results) >= max_results:
break
try:
sf = subfolders.item(i)
sub_path = f"{path}/{sf.name}" if path else sf.name
queue.append((sf, sub_path, depth + 1))
except Exception:
continue
except Exception as e:
folders_skipped += 1
try:
adsk.core.Application.log(
f"AdomBridge: search dataFolders error in '{path}': {e}"
)
except Exception:
pass
finally:
with _walk_progress_lock:
_walk_progress = None
hit_folder_limit = folders_visited >= max_folders
hit_result_limit = len(results) >= max_results
elapsed_total = time.time() - search_start
# ── searchComplete: TRUE only if we exhausted the search space ──
# Any cap hit OR any skip means we might have missed matches.
# The AI should treat "0 results + searchComplete=false" as
# "result unknown", never as "confirmed not present".
search_complete = (
not hit_folder_limit
and not hit_result_limit
and not search_timed_out
and folders_skipped == 0
and files_skipped == 0
)
# Cost analysis — useful for both reporting back and informing
# whether to ask the user for narrower scope next time.
folders_per_sec = (folders_visited / elapsed_total) if elapsed_total > 0 else 0.0
cost_analysis = {
"elapsedSeconds": round(elapsed_total, 1),
"foldersPerSecond": round(folders_per_sec, 2),
# Rough estimate based on observed throughput. If the user wanted
# an exhaustive sweep of the entire project, this is how long it
# might take given THIS search's average folder latency. (Can't
# know the project's actual folder count without walking it.)
"estimatedSecondsPer100Folders": round(100 / folders_per_sec) if folders_per_sec > 0 else None,
}
# ── Build a hint that's EXPLICIT about what kind of "0" we got ──
hint_parts = []
if search_complete:
if len(results) == 0:
hint_parts.append(
f"COMPLETE SEARCH: searched {folders_visited} folder(s) in "
f"{round(elapsed_total)}s and found NO files matching '{query}'. "
f"This is a confirmed negative — the file does not exist in "
f"'{folder_path or project.name}'."
)
else:
hint_parts.append(
f"COMPLETE SEARCH: all {len(results)} matches returned."
)
else:
# Search was incomplete — be explicit about WHY and what it implies
reasons = []
if hit_folder_limit:
reasons.append(f"hit maxFolders={max_folders}")
if hit_result_limit:
reasons.append(f"hit maxResults={max_results}")
if search_timed_out:
reasons.append(f"hit searchTimeout={SEARCH_TIMEOUT}s")
if folders_skipped > 0:
reasons.append(f"{folders_skipped} folder(s) errored/timed out")
if files_skipped > 0:
reasons.append(f"{files_skipped} file(s) errored on .name access")
hint_parts.append(
f"INCOMPLETE SEARCH ({'; '.join(reasons)}). "
f"Found {len(results)} match(es) in {folders_visited} folder(s) "
f"over {round(elapsed_total)}s — but this is a LOWER BOUND, not exhaustive."
)
if len(results) == 0:
hint_parts.append(
"CRITICAL: 0 results does NOT mean the file is absent. The search "
"stopped before exhausting the scope. Do NOT report 'file not found' "
"to the user — re-run with broader caps OR narrower folderPath first."
)
# ── Proactive scope-narrowing nudge for broad searches ──
# If this was a wide search and took non-trivial time, the AI should
# (a) tell the user why this is slow (Autodesk API design, NOT adom-desktop)
# (b) suggest the simple fix (folderPath narrowing)
# (c) mention the long-term community fix (paid APS, fork + PR)
if recursive and not folder_path and elapsed_total > 30:
cost_str = (
f" (at the observed {folders_per_sec:.1f} folders/sec, "
f"a 10-folder subtree would take ~{round(10 / folders_per_sec)}s "
f"vs this {round(elapsed_total)}s scan)"
if folders_per_sec > 0 else ""
)
hint_parts.append(
f"BROAD-SCOPE NOTE: searched '{project.name}' from root in "
f"{round(elapsed_total)}s. WHY SLOW: Autodesk's free Fusion 360 "
f"Python API has no indexed file-search endpoint, so we must walk "
f"the cloud Data API one folder at a time — each folder is a "
f"separate HTTP round-trip to Autodesk. THIS IS AN AUTODESK API "
f"LIMITATION, NOT AN ADOM-DESKTOP LIMITATION — when you tell the "
f"user it's slow, attribute it to Autodesk / Fusion 360, not to "
f"adom-desktop's bridge. Autodesk's PAID Autodesk Platform Services "
f"(APS, formerly Forge) Data Management API DOES expose indexed "
f"search that would make this ~instant; if your team has APS "
f"credentials, you (or anyone in the community) can fork the "
f"adom-desktop fusion bridge to add an aps_search verb and PR it "
f"back to https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop "
f"— the whole Adom community would benefit. FOR THIS SEARCH "
f"RIGHT NOW: just ask the user 'do you know which subfolder "
f"<query> is typically stored in?'{cost_str} Naming a folder "
f"like 'Molecules' cuts search 10-50x with zero new API."
)
elif not recursive and len(results) == 0:
hint_parts.append(
"Search was limited to root folder (recursive=false). If the file "
"could be in a subfolder, set recursive=true and ideally folderPath "
"to the most likely parent (ask the user if unsure)."
)
# If we hit the result cap, more matches may exist
if hit_result_limit:
hint_parts.append(
f"RESULT CAP HIT: you got the first {max_results} matches but more "
f"may exist. Raise maxResults to see all."
)
full_hint = " | ".join(hint_parts) if hint_parts else None
return {
"success": True,
"output": f"Found {len(results)} file(s) matching '{query}' "
f"(searched {folders_visited} folder(s) in '{project.name}'"
f"{', search timed out' if search_timed_out else ''}"
f"{', INCOMPLETE' if not search_complete else ''})",
"data": {
"query": query,
"files": results,
"totalFound": len(results),
"foldersSearched": folders_visited,
"foldersSkipped": folders_skipped,
"filesSkipped": files_skipped,
"project": project.name,
"scopeFolderPath": folder_path or "(project root)",
"recursive": recursive,
# ── The big flag the AI must check before reporting "no match" ──
"searchComplete": search_complete,
"truncated": hit_result_limit,
"folderLimitReached": hit_folder_limit,
"searchTimedOut": search_timed_out,
"elapsedSeconds": round(elapsed_total, 1),
"costAnalysis": cost_analysis,
"hint": full_hint,
},
"_hint": full_hint, # also at top level for AI scaffolding that checks _hint
}
def handle_export_cloud_file(app: adsk.core.Application, args: dict) -> dict:
"""Export the active Fusion document to a local file for transfer.
Exports the currently open document as STEP, STL, F3D, or other format
to a local file path. The file can then be pulled back to Docker via pull_file.
Args:
args: Dict with:
- outputPath: Local file path to export to (required).
- format: Export format (optional, default "step").
Supported: "step", "stl", "f3d", "iges", "sat", "smt".
"""
output_path = args.get("outputPath", "").strip()
if not output_path:
return {"success": False, "error": "No outputPath specified."}
output_path = output_path.replace("\\", "/")
fmt = args.get("format", "step").lower().strip()
doc = app.activeDocument
if not doc:
return {
"success": False,
"error": "No active document to export.",
"_hint": "Call fusion_open_cloud_file to open a file, then retry.",
}
# Get the design product
design = None
try:
design = adsk.fusion.Design.cast(app.activeProduct)
except Exception:
pass
if not design:
return {
"success": False,
"error": "Active document is not a Fusion Design. Cannot export.",
"_hint": "Call fusion_activate_workspace with target='design' or open a Fusion Design document first, then retry.",
"data": {"hint": "Open a design document first, or switch to the Design workspace."},
}
# Ensure output directory exists
out_dir = os.path.dirname(output_path)
if out_dir and not os.path.exists(out_dir):
os.makedirs(out_dir, exist_ok=True)
root = design.rootComponent
export_mgr = design.exportManager
try:
if fmt in ("step", "stp"):
options = export_mgr.createSTEPExportOptions(output_path, root)
export_mgr.execute(options)
elif fmt == "stl":
options = export_mgr.createSTLExportOptions(root, output_path)
export_mgr.execute(options)
elif fmt in ("iges", "igs"):
options = export_mgr.createIGESExportOptions(output_path, root)
export_mgr.execute(options)
elif fmt == "sat":
options = export_mgr.createSATExportOptions(output_path, root)
export_mgr.execute(options)
elif fmt == "smt":
options = export_mgr.createSMTExportOptions(output_path, root)
export_mgr.execute(options)
elif fmt == "f3d":
options = export_mgr.createFusionArchiveExportOptions(output_path)
export_mgr.execute(options)
else:
return {
"success": False,
"error": f"Unsupported format: {fmt}",
"data": {"supportedFormats": ["step", "stl", "f3d", "iges", "sat", "smt"]},
}
except Exception as e:
return {"success": False, "error": f"Export failed: {e}"}
if not os.path.exists(output_path):
return {"success": False, "error": f"Export file was not created at {output_path}"}
size = os.path.getsize(output_path)
return {
"success": True,
"output": f"Exported '{doc.name}' as {fmt.upper()} to {output_path} ({size} bytes)",
"data": {
"outputPath": output_path,
"format": fmt,
"fileSize": size,
"documentName": doc.name,
},
}
def handle_walk_cloud_tree(app: adsk.core.Application, args: dict) -> dict:
"""Walk a cloud folder tree iteratively and return a flat list of all files and folders.
[WARNING - DO NOT USE FOR SEARCH] Goes through Fusion's rate-limited add-in
Data API: SLOW (30+ MINUTES on a real project) and CAN CRASH the live Fusion
session (add-in connection forcibly closed, WinError 10054 - Fusion goes
down). Use fusion_aps_search instead (APS server-side index, seconds). See
the 'fusion-aps-search' skill. Last-resort only, when APS is unconfigured
and you accept the crash risk.
Purpose: replace the broken recursive search_cloud_files. No sleeps, no tiny
hardcoded caps. Single add-in call does the whole BFS inside the main thread
— one request to the bridge, one response back.
Args:
args: Dict with:
- projectName: Project name (optional, defaults to active project).
- folderPath: Starting folder path (optional, defaults to root).
- maxDepth: Max recursion depth (optional, default 10).
- maxFolders: Hard cap on number of folders visited (optional, default 500).
- extensions: List of file extensions to include, e.g. ["f3d", "fprj"]
(optional, default: include all files).
- nameContains: Substring filter for file/folder names, case-insensitive
(optional, default: no filter).
- includeFiles: If False, return only the folder tree (default True).
Returns data:
- project: project name
- rootFolder: starting folder name
- folders: [{path, name, id, depth}]
- files: [{path, folder, name, id, fileExtension, versionNumber}]
- stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}
"""
project_name = args.get("projectName", "") or None
folder_path = args.get("folderPath", "") or None
max_depth = int(args.get("maxDepth", 10))
max_folders = int(args.get("maxFolders", 500))
extensions = args.get("extensions") or []
if isinstance(extensions, str):
extensions = [extensions]
extensions = {e.lower().lstrip(".") for e in extensions}
name_contains = (args.get("nameContains") or "").lower()
include_files = args.get("includeFiles", True)
project, err = _resolve_project(app, project_name)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_projects to see valid project names.",
}
root, err = _resolve_folder(project, folder_path)
if err:
return {
"success": False,
"error": err,
"_hint": "Call fusion_list_cloud_files with this projectName to see valid folderPath values.",
}
folders_out = []
files_out = []
folders_visited = 0
folders_skipped = 0
files_found = 0
max_depth_reached = 0
truncated = False
# Iterative BFS queue: list of (folder, path, depth)
start_path = root.name if folder_path else ""
queue = [(root, start_path, 0)]
# Set progress state so external callers (health endpoint, bridge busy
# gate) know a walk is running and can report progress.
global _walk_progress
walk_start = time.time()
with _walk_progress_lock:
_walk_progress = {
"command": "walk_cloud_tree",
"startedAt": walk_start,
"project": project.name,
"rootFolder": root.name,
"foldersVisited": 0,
"queueSize": len(queue),
"currentFolder": start_path or "(root)",
"filesFound": 0,
"elapsedSeconds": 0,
}
try:
while queue:
if folders_visited >= max_folders:
truncated = True
break
folder, path, depth = queue.pop(0)
folders_visited += 1
if depth > max_depth_reached:
max_depth_reached = depth
# Per-folder wall-clock timeout — if a single folder's cloud API
# calls take >30s, skip remaining work and move on. We can't
# interrupt folder.dataFiles mid-call, but we can skip subfolder
# enumeration and file iteration if the first call was too slow.
FOLDER_TIMEOUT = 30 # seconds
folder_start = time.time()
adsk.core.Application.log(
f"AdomBridge: walk [{folders_visited}/{folders_visited + len(queue)}] '{path}'"
)
folders_out.append({
"path": path,
"name": folder.name,
"id": folder.id,
"depth": depth,
})
# List files in this folder
if include_files:
try:
data_files = folder.dataFiles
if time.time() - folder_start > FOLDER_TIMEOUT:
adsk.core.Application.log(
f"AdomBridge: walk skipping slow folder '{path}' (>{FOLDER_TIMEOUT}s on dataFiles)"
)
folders_skipped += 1
else:
for i in range(data_files.count):
# Yield every 50 files to keep Fusion responsive
if i > 0 and i % 50 == 0:
try:
adsk.doEvents()
except Exception:
pass
if time.time() - folder_start > FOLDER_TIMEOUT:
adsk.core.Application.log(
f"AdomBridge: walk folder '{path}' hit timeout during file iteration at file {i}"
)
folders_skipped += 1
break
try:
df = data_files.item(i)
name = df.name
ext = ""
try:
ext = (df.fileExtension or "").lower()
except Exception:
pass
if extensions and ext not in extensions:
continue
if name_contains and name_contains not in name.lower():
continue
entry = {
"path": (path + "/" + name) if path else name,
"folder": path,
"name": name,
"id": df.id,
"fileExtension": ext,
}
try:
entry["versionNumber"] = df.versionNumber
except Exception:
pass
files_out.append(entry)
files_found += 1
except Exception:
continue
except Exception:
folders_skipped += 1
# Enqueue subfolders (unless at depth limit or folder timed out)
if depth < max_depth and (time.time() - folder_start <= FOLDER_TIMEOUT):
try:
subfolders = folder.dataFolders
for i in range(subfolders.count):
try:
sf = subfolders.item(i)
sub_path = (path + "/" + sf.name) if path else sf.name
queue.append((sf, sub_path, depth + 1))
except Exception:
continue
except Exception:
folders_skipped += 1
elif depth < max_depth:
adsk.core.Application.log(
f"AdomBridge: walk skipping subfolders of '{path}' (folder timed out)"
)
# Update progress for external observers (health endpoint, bridge)
with _walk_progress_lock:
if _walk_progress is not None:
_walk_progress["foldersVisited"] = folders_visited
_walk_progress["queueSize"] = len(queue)
_walk_progress["currentFolder"] = path
_walk_progress["filesFound"] = files_found
_walk_progress["elapsedSeconds"] = round(time.time() - walk_start, 1)
# Yield to Fusion's event loop to prevent "Not Responding" state.
try:
adsk.doEvents()
except Exception:
pass
finally:
with _walk_progress_lock:
_walk_progress = None
summary = (
f"Walked {folders_visited} folders under '{root.name}' in project '{project.name}' "
f"(max depth {max_depth_reached}). Found {files_found} files."
)
if truncated:
summary += f" TRUNCATED at maxFolders={max_folders} — increase limit or narrow folderPath."
return {
"success": True,
"output": summary,
"data": {
"project": project.name,
"rootFolder": root.name,
"folders": folders_out,
"files": files_out,
"stats": {
"foldersVisited": folders_visited,
"foldersSkipped": folders_skipped,
"filesFound": files_found,
"maxDepthReached": max_depth_reached,
"truncated": truncated,
},
},
}
def handle_open_by_urn(app: adsk.core.Application, args: dict) -> dict:
"""Open a cloud file directly by its APS/Fusion URN.
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.
Works for ANY file regardless of folder nesting (unlike open_cloud_file,
which searches by name only in a project's root folder). APS hands the
bridge the exact file URN; we resolve it to a Fusion DataFile via
Data.findFileById and open it.
"""
urn = (args.get("urn") or args.get("fileUrn") or "").strip()
if not urn:
return {"success": False, "error": "No urn specified."}
# File-based breadcrumb (Application.log only reaches the in-app Text Commands
# palette, which is unreadable headless). Opening a heavy assembly or an
# electronics/PCB design can hard-crash the host mid-open with no other trace;
# this leaves the last step on disk so the crash point is provable afterward.
import os as _os, tempfile as _tf
_crumb_path = _os.path.join(_tf.gettempdir(), "adom_open_by_urn.log")
def _crumb(msg):
try:
with open(_crumb_path, "a", encoding="utf-8") as _fh:
_fh.write(msg + "\n")
_fh.flush()
except Exception:
pass
try:
adsk.core.Application.log("[open_by_urn] " + msg)
except Exception:
pass
data = app.data
# APS returns a version urn like urn:adsk.wipprod:fs.file:vf.<base>?version=N.
# findFileById wants the file/lineage id — try several derivations.
raw = urn.split("?")[0]
candidates = [urn, raw]
if "fs.file:vf." in raw:
base = raw.split("fs.file:vf.")[-1]
candidates.append("urn:adsk.wipprod:dm.lineage:" + base)
candidates.append(base)
_crumb("resolving urn: " + urn[:80])
df = None
tried = []
for cid in candidates:
try:
f = data.findFileById(cid)
if f:
df = f
tried.append(cid[:55] + " -> FOUND")
break
tried.append(cid[:55] + " -> none")
except Exception as e:
tried.append(cid[:55] + " -> " + str(e)[:40])
if not df:
_crumb("FAILED to resolve urn to a DataFile. tried=" + " | ".join(tried))
return {"success": False,
"error": "Could not resolve the file URN to a Fusion DataFile.",
"_hint": "URN resolution failed — an open-path issue, NOT the Fusion license. A "
"read-only/expired/trial Fusion still opens+views files (only save/export "
"are blocked). Check the urn form / file access.",
"data": {"tried": tried, "urn": urn}}
_crumb("resolved '%s' (id=%s) — about to call documents.open "
"(PCB/electronics designs spin up the Electronics editor here; a heavy "
"assembly downloads all referenced parts here — the wedge/crash point)"
% (df.name, df.id))
doc = None
last_err = None
for attempt in range(3):
try:
doc = app.documents.open(df)
if doc:
_crumb("documents.open returned a doc for '%s'" % df.name)
break
except Exception as e:
last_err = str(e)
if "InternalValidationError" in last_err and attempt < 2:
time.sleep(2 + attempt * 2)
continue
break
if not doc:
return {"success": False, "error": "Open failed for '%s': %s" % (df.name, last_err),
"_hint": "documents.open failed — an open-path issue, NOT the Fusion license. A "
"read-only/expired/trial Fusion still opens+views files (only save/export "
"are blocked). Likely a slow/large assembly, a blocking Electronics picker, "
"or a transient cloud error — retry / check fusion_window_info for a modal.",
"data": {"fileName": df.name, "fileId": df.id}}
try:
doc.activate()
except Exception:
pass
_rep = _ecad_open_reprimand(app)
return {"success": True, "output": ((_rep + " || ") if _rep.startswith("STOP") else "") + ("Opened '%s'." % df.name),
"_hint": _rep,
"data": {"fileName": df.name, "fileId": df.id, "resolved": tried}}