app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 162 file(s)
19 files changed
+153−51
BRIDGE_VERSION+1−1addin/AdomBridge/commands/app_state.py+1−1addin/AdomBridge/commands/cloud_documents.py+24−6addin/AdomBridge/http_server.py+1−1bridge.json+1−1contracts/describe.json+8−4dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+4−4install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/bridge/src/main.rs+19rust/bridge/src/orchestrations.rs+78−1tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.shBRIDGE_VERSION+1−1@@ -1 +1 @@-1.10.12+1.10.13
addin/AdomBridge/commands/app_state.py+1−1@@ -93,7 +93,7 @@ def handle_get_app_state(app: adsk.core.Application, args: dict) -> dict: else: hints.append("Not in Electronics workspace. Use fusion_show_2d_board to enter board view, " "or fusion_open_cloud_file to open an electronics project (.fprj).")- hints.append("Check fusion_window_info for blocking dialogs before running commands.")+ hints.append("Check fusion_state {screenshot:false} (dialogs + focusEvents) before running commands if something looks stuck.") hint = " ".join(hints) return {
addin/AdomBridge/commands/cloud_documents.py+24−6@@ -1,3 +1,4 @@+import re """Cloud document management for Fusion 360. Provides commands to save documents to Autodesk's cloud hub,@@ -841,7 +842,7 @@ def handle_check_recovery(app: adsk.core.Application, args: dict) -> dict: } -def _ecad_open_reprimand(app):+def _ecad_open_reprimand(app, intent=None): """Detect when an AI opened an electronics CHILD file instead of the PROJECT. Electronics files are a parent/child chain: PROJECT (EcadDesignProductType) ->@@ -861,9 +862,26 @@ def _ecad_open_reprimand(app): ' 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.')+ if pt == 'DesignProductType':+ # A DesignProductType is every mechanical design in Fusion; it is only the wrong file+ # when it is the 3D leaf of an electronics chain (issue #86: a legitimate mechanical+ # open was told "STOP - WRONG FILE"). Judge by evidence, or by the caller's stated intent.+ if intent == 'mechanical':+ return 'OK: opened a mechanical design (DesignProductType), as requested.'+ name = ''+ try:+ name = (app.activeDocument.name or '') if app.activeDocument else ''+ except Exception:+ pass+ looks_ecad = bool(re.search(r'(?i)(\b3d\b|_3d\b|\bpcb\b|\bboard\b)', name))+ if intent == 'electronics' or looks_ecad:+ return ('STOP - WRONG FILE: you opened a 3D model file (DesignProductType) that looks like '+ 'the 3D leaf of an electronics design, not the project.' + nav)+ return ('OK: opened a mechanical design (DesignProductType). If you actually wanted an '+ 'electronics board, its 3D is a child of the PROJECT (EcadDesignProductType): open '+ 'the project and switch views there; pass intent:"electronics" to make this a refusal.') label = {'SchematicProductType': 'the SCHEMATIC',- 'BoardProductType': 'the BOARD (.brd) / its 3D PCB',- 'DesignProductType': 'a 3D model file'}.get(pt)+ 'BoardProductType': 'the BOARD (.brd) / its 3D PCB'}.get(pt) if label: return ('STOP - WRONG FILE: you opened ' + label + ' (' + pt + ') directly, not the project.' + nav)@@ -985,7 +1003,7 @@ def handle_open_cloud_file(app: adsk.core.Application, args: dict) -> dict: except Exception: pass - _rep = _ecad_open_reprimand(app)+ _rep = _ecad_open_reprimand(app, (args or {}).get('intent')) return { "success": True, "output": ((_rep + " || ") if _rep.startswith("STOP") else "") + f"Opened '{target.name}' from project '{project.name}'",@@ -1883,13 +1901,13 @@ def handle_open_by_urn(app: adsk.core.Application, args: dict) -> dict: "_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.",+ "or a transient cloud error — retry / check fusion_state {screenshot:false} for a modal.", "data": {"fileName": df.name, "fileId": df.id}} try: doc.activate() except Exception: pass- _rep = _ecad_open_reprimand(app)+ _rep = _ecad_open_reprimand(app, (args or {}).get('intent')) 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}}
addin/AdomBridge/http_server.py+1−1@@ -399,7 +399,7 @@ class AdomBridgeHandler(BaseHTTPRequestHandler): "_hint": ( "Add-in is busy with a long-running command. Do NOT retry add-in commands. " "Do NOT press Escape — the add-in is working, not stuck on a dialog. "- "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "+ "Commands that still work: fusion_state, fusion_screenshot_fusion, " "fusion_click_fusion, fusion_send_key, fusion_close_window." ), }
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.10.12",+ "version": "1.10.13", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
contracts/describe.json+8−4@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-09-10",+ "frozen": "2026-09-19", "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.", "source": "describe.py", "verbs": [@@ -250,7 +250,7 @@ "results": "[{name,id,projectName,lastModified}]" }, "statusVerb": null,- "summary": "Server-indexed search across the whole team hub (seconds, not a 30-min folder walk).",+ "summary": "Server-indexed search across the whole team hub (seconds, not a 30-min folder walk). A ZERO from the server index is not absence: the reply then carries data.fallback (a name scan of the newest 400 items), data.completeness, and a hint to fusion_aps_browse a known sibling's folder (issue #85).", "timeoutSeconds": 60 }, {@@ -2017,6 +2017,7 @@ "urn": "urn:adsk.wipprod:fs.file:vf.xxxx?version=1" }, "input": {+ "intent": "optional \"mechanical\" | \"electronics\": what you meant to open. A DesignProductType is every mechanical design; the STOP-WRONG-FILE hint now fires only for the 3D leaf of an electronics chain (by name, or intent electronics); intent mechanical confirms the open (issue #86)", "urn": "required str" }, "longRunning": true,@@ -2064,7 +2065,8 @@ "filePath": "C:/Temp/part.f3d" }, "input": {- "filePath": "required str (WINDOWS path)"+ "filePath": "required str (WINDOWS path)",+ "intent": "optional \"mechanical\" | \"electronics\" (see fusion_open_by_urn)" }, "longRunning": true, "name": "fusion_open_design",@@ -2543,7 +2545,9 @@ { "example": {}, "input": {- "enabled": "optional bool (omit to read)"+ "enabled": "optional bool (omit to read)",+ "nativeViewport": "optional bool (default true with screenshot): also export the native viewport frame and count bodies; the reply carries nativeViewport {path, ok, bodies, productType}, geometry {bodies} (API truth, independent of pixels) and windowPixels {dominantColorFraction, blank} for the window capture. A blank window capture of a Design viewport is a known WGC artefact: trust nativeViewport, never foreground Fusion for a capture (issue #84)",+ "viewport": "optional str: \"native\" = only the frame Fusion itself renders (activeViewport.saveAsImageFile through the add-in), no window capture" }, "longRunning": false, "name": "fusion_set_driving_badge",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.10.12"+version = "1.10.13" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.10.12"+version = "1.10.13" edition = "2021" [dependencies]
describe.py+4−4@@ -106,13 +106,13 @@ _T = [ {}, {"success": "bool", "browser": "{name,exe,profileDir}|null"}, 10, None, False, {}), ("fusion_aps_forget_browser", "Forget the remembered Autodesk sign-in browser (re-detect next time).", {}, {"success": "bool", "cleared": "bool"}, 10, None, False, {}),- ("fusion_aps_search", "Server-indexed search across the whole team hub (seconds, not a 30-min folder walk).",+ ("fusion_aps_search", "Server-indexed search across the whole team hub (seconds, not a 30-min folder walk). A ZERO from the server index is not absence: the reply then carries data.fallback (a name scan of the newest 400 items), data.completeness, and a hint to fusion_aps_browse a known sibling's folder (issue #85).", {"query": "required str", "limit": "optional int (default 50)", "hubId": "optional str", "projectId": "optional str"}, {"count": "int", "results": "[{name,id,projectName,lastModified}]"}, 60, None, False, {"query": "Molecule", "limit": 10}), ("fusion_aps_open", "Find a cloud design by name and open it in Fusion (any folder depth). Fire-and-poll.", {"query": "required str", "wait": "optional bool"}, {"opening": "bool", "match": "{name}", "statusVerb": "str"}, 60, _STATUS, True, {"query": "BME690 Molecule"}), ("fusion_open_by_urn", "Open a cloud ELECTRONICS DESIGN by 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.",- {"urn": "required str"}, {"success": "bool", "output": "str"}, 240, _STATUS, True, {"urn": "urn:adsk.wipprod:fs.file:vf.xxxx?version=1"}),+ {"urn": "required str", "intent": "optional \"mechanical\" | \"electronics\": what you meant to open. A DesignProductType is every mechanical design; the STOP-WRONG-FILE hint now fires only for the 3D leaf of an electronics chain (by name, or intent electronics); intent mechanical confirms the open (issue #86)"}, {"success": "bool", "output": "str"}, 240, _STATUS, True, {"urn": "urn:adsk.wipprod:fs.file:vf.xxxx?version=1"}), ("fusion_aps_browse", "Browse hubs/projects/folders without launching Fusion.", {"hubId": "optional str", "projectId": "optional str", "folderId": "optional str"}, {"items": "[{name,type,id}]"}, 60, None, False, {}), ("fusion_aps_recent", "Most-recently-modified designs across the team hub, newest first.",@@ -230,7 +230,7 @@ _T = [ # the verb suite's reverse drift check. An undescribed verb is invisible to ab's Verbs # tab and to every caller reading fusion_describe - never let these drift again. ("fusion_open_cloud_file", "Open a CLOUD file by name (the front door to every design flow). PCB/ELECTRONICS NAV RULE: open the electronics DESIGN document (EcadDesignProductType), never a child .brd/.sch/3D directly - then switch views with fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board. LONG-RUNNING: fire it, then poll fusion_get_app_state until activeDocument matches (cloud opens on electronics designs take 1-3 min). A crash-recovery modal can block the open - fusion_dismiss_blocking_dialogs.", {"fileName": "required str", "projectName": "optional str (defaults to active project)", "folderPath": "optional str"}, {"success": "bool", "documentName": "str"}, 240, "fusion_get_app_state", True, {"fileName": "BQ25792", "projectName": "Main"}),- ("fusion_open_design", "Open a LOCAL design file (routes through the add-in import). Prefer cloud flows (fusion_open_cloud_file) for team designs; this is for local .f3d/.step style files.", {"filePath": "required str (WINDOWS path)"}, {"success": "bool"}, 240, None, True, {"filePath": "C:/Temp/part.f3d"}),+ ("fusion_open_design", "Open a LOCAL design file (routes through the add-in import). Prefer cloud flows (fusion_open_cloud_file) for team designs; this is for local .f3d/.step style files.", {"filePath": "required str (WINDOWS path)", "intent": "optional \"mechanical\" | \"electronics\" (see fusion_open_by_urn)"}, {"success": "bool"}, 240, None, True, {"filePath": "C:/Temp/part.f3d"}), ("fusion_close_document", "Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). Omit name to close the active document.", {"name": "optional str (default: active document)", "save": "optional bool default false"}, {"success": "bool", "closed": "str"}, 60, None, False, {"save": False}), ("fusion_close_all_documents", "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.", {"save": "optional bool default false", "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs", "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs"}, {"success": "bool", "closed": "[str]", "wouldPrompt": "[str]|null"}, 60, None, False, {"closeUntitled": True}), ("fusion_reset_workspace", "Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). Fusion always keeps ONE blank document alive, so a single remaining Untitled is correct rather than a failure. MODIFIED Untitled docs are left untouched unless force:true, because those can hold real unsaved work.", {"force": "optional bool default false - also discard MODIFIED Untitled docs"}, {"success": "bool", "closedByAddin": "list", "untitledScratchClosed": "int", "remaining": "list"}, 90, None, False, {}),@@ -257,7 +257,7 @@ _T = [ ("fusion_show_library", "Eight-surfaces contract: the LIBRARY surface. libraryPath (WINDOWS .lbr) opens it; name alone handles the already-open case (symmetric with kicad_show_library). Background; returns a screenshot path.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "libraryPath": "optional str (WINDOWS .lbr)", "name": "optional str (already-open library name)"}, {"success": "bool", "view": "str", "screenshot": "str"}, 240, None, True, {"libraryPath": "C:/Temp/PART.lbr"}), ("fusion_show_project", "Eight-surfaces contract: bring the PROJECT/design to its top level. No args = activate this session's generated test project (fusion_make_test_project). query = cloud design name (already-open match wins, else fire-and-poll open). urn = open_by_urn passthrough. Background; returns a screenshot path.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "query": "optional str (cloud design name)", "urn": "optional str", "fileName": "alias of query"}, {"success": "bool", "view": "str", "document": "str", "screenshot": "str"}, 240, "fusion_get_app_state", True, {"query": "BQ25792"}), ("fusion_set_window_sizing", "Read/toggle auto-sizing Fusion's WINDOW after launch (STICKY, default ON). Fusion opens at whatever geometry it last had, which after an automated launch is often a small default; the bridge grows it to ~92% of the monitor work area, centered, using SetWindowPos with NOACTIVATE (never ShowWindow/maximize) so focus is never stolen. A window the user maximized, or already sized to >=60% of the work area, is left alone. No args = read.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "sizeFusionWindow": "bool"}, 15, "fusion_state", False, {}),- ("fusion_set_driving_badge", "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "showDrivingBadge": "bool"}, 15, "fusion_state", False, {}),+ ("fusion_set_driving_badge", "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", {"enabled": "optional bool (omit to read)", "viewport": "optional str: \"native\" = only the frame Fusion itself renders (activeViewport.saveAsImageFile through the add-in), no window capture", "nativeViewport": "optional bool (default true with screenshot): also export the native viewport frame and count bodies; the reply carries nativeViewport {path, ok, bodies, productType}, geometry {bodies} (API truth, independent of pixels) and windowPixels {dominantColorFraction, blank} for the window capture. A blank window capture of a Design viewport is a known WGC artefact: trust nativeViewport, never foreground Fusion for a capture (issue #84)"}, {"success": "bool", "showDrivingBadge": "bool"}, 15, "fusion_state", False, {}), ("fusion_resolve_seat_dialog", "Readiness EXECUTOR (Rust port, Phase 3): detect + resolve the Autodesk seat/licensing modal ('Active Sessions Exceeded' / 'Suspend Remote Session') in the background, at most 3 UIA clicks per call, screenshot-verified. Idempotent: nothing up answers clicks:0. fusion_readiness calls this itself when it sees the dialog; call it directly only when you are driving the launch by hand.", {"maxClicks": "optional int (default 3)"}, {"clicks": "int", "resolved": "bool"}, 60, None, False, {}), ("fusion_apply_fusion_update", "Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). Never the exit-class buttons while a host op is active. Answers {applied, button, why}.", {}, {"applied": "bool", "button": "str|null", "why": "str"}, 60, None, False, {}), ("fusion_set_seat_takeover", "Read/toggle the mid-session Autodesk seat-takeover sweep (STICKY per user, default ON). When Autodesk pops 'Active Sessions Exceeded' mid-session, the bridge clicks Continue through BOTH dialogs in the background: this machine takes the seat, the other machine's Fusion suspends RESUMABLY, the user gets one toast. No args = read current value. Gates only the passive sweep - a verb-driven fusion_start still grabs the seat (the user asked for Fusion, so the seat is implied).", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "autoTakeOverSeat": "bool"}, 15, "fusion_readiness", False, {}),
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.10.12"+DASH_VERSION="1.10.13" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.10.12",+ "version": "1.10.13", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.10.12",+ "version": "1.10.13", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.10.12"+version = "1.10.13" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+19@@ -786,6 +786,25 @@ pub(crate) fn native_aps_search(args: &Value) -> Value { results.sort_by(|a, b| b["lastModified"].as_str().unwrap_or("").cmp(a["lastModified"].as_str().unwrap_or(""))); let n = results.len(); let shown: Vec<Value> = results.into_iter().take(limit).collect();+ if n == 0 {+ // Zero from the server index is not "the file does not exist" (issue #85: a model the+ // index missed sat in the same folder as its sibling hit). Fall back to a name scan of+ // the recently modified items the bridge can list without a full walk.+ let ql = query.to_lowercase();+ let toks: Vec<&str> = ql.split_whitespace().collect();+ let recent = native_aps_recent(&json!({"limit": 400, "hubId": args["hubId"], "projectId": args["projectId"]}));+ let fb: Vec<Value> = recent["data"]["results"].as_array().cloned().unwrap_or_default().into_iter()+ .filter(|i| { let nm = i["name"].as_str().unwrap_or("").to_lowercase(); !toks.is_empty() && toks.iter().all(|t| nm.contains(t)) }).take(limit).collect();+ let nf = fb.len();+ let folder_hint = fb.first().and_then(|f| f["folderId"].as_str().or(f["parentFolderId"].as_str())).map(|f| format!(" The first fallback hit lives in folder {f}; fusion_aps_browse {{folderId}} lists that folder in full.")).unwrap_or_default();+ return json!({"success": true, "output": format!("0 index match(es) for '{query}'; {nf} by name in recent items"),+ "data": {"query": query, "count": 0, "results": [], "scanned": {"hubs": scanned.0, "projects": scanned.1, "folders": scanned.2},+ "fallback": {"source": "recent-items name scan (newest 400 across the scanned projects)", "count": nf, "results": fb},+ "completeness": "The APS server index can lag a new or renamed file by minutes to hours and tokenises names, so a zero here does not prove absence."},+ "shell": "rust",+ "_hint": format!("No index match for '{query}'.{} {}Next: fusion_aps_browse a known sibling's folder (scoped, no cloud walk), or pass projectId to narrow the scan. Do not conclude the file is missing from an index zero alone.",+ if nf > 0 { format!(" {nf} recently modified item(s) match by name (data.fallback); open one with fusion_aps_open or fusion_open_by_urn.") } else { " Nothing in the newest 400 items matches by name either.".to_string() }, folder_hint)});+ } json!({"success": true, "output": format!("{n} match(es) for '{query}'"), "data": {"query": query, "count": n, "results": shown, "scanned": {"hubs": scanned.0, "projects": scanned.1, "folders": scanned.2}}, "shell": "rust", "_hint": format!("Found {n} match(es) via the APS server index across {} project(s), newest first. Pass projectId to scope to one project (faster). Use fusion_aps_open to open a hit in Fusion.", scanned.1)})
rust/bridge/src/orchestrations.rs+78−1@@ -580,11 +580,32 @@ pub fn fusion_state(ctx: &Ctx, args: &Value) -> Value { let app = ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(5)).send_json(json!({"command": "get_app_state", "args": {}})).ok().and_then(|r| r.into_json::<Value>().ok()); if let Some(a) = app { result["activeDocument"] = a["data"]["activeDocument"].clone(); } }- if args["screenshot"].as_bool() != Some(false) {+ let want_native = args["nativeViewport"].as_bool() != Some(false) && args["screenshot"].as_bool() != Some(false);+ let native_only = args["viewport"].as_str() == Some("native");+ if want_native {+ // Issue #84: a WGC window capture of a GPU Design viewport can be blank or stale while+ // the document holds hundreds of bodies. Ask Fusion itself for the frame and the body+ // count, so geometry presence is reported separately from window pixels.+ let png_path = crate::fusionpath::stage_root(&sd).map(|r| r.join(format!("native-viewport-{}.png", (now_f() * 1000.0) as u64)).to_string_lossy().replace('\\', "/")).unwrap_or_else(|_| sd.join("native-viewport.png").to_string_lossy().replace('\\', "/"));+ let script = format!("import adsk.core, adsk.fusion\napp=adsk.core.Application.get(); res={{}}\nd=app.activeDocument; res['document']=d.name if d else None\np=app.activeProduct; res['productType']=p.productType if p else None\nacc=[0]\ndef _walk(c):\n acc[0]+=c.bRepBodies.count\n for i in range(c.occurrences.count): _walk(c.occurrences.item(i).component)\ntry:\n ds=adsk.fusion.Design.cast(p)\n if ds: _walk(ds.rootComponent); res['bodies']=acc[0]\nexcept Exception as e: res['bodiesError']=str(e)[:80]\nvp=app.activeViewport\nif vp:\n try:\n ok=vp.saveAsImageFile('{png_path}', 1600, 1000); res['path']='{png_path}' if ok else None; res['ok']=bool(ok)\n except Exception as e: res['viewportError']=str(e)[:100]\nresult=res\n");+ let r = crate::addin::addin_http("run_modeling_script", &json!({"script": script}), 25);+ let nv = r["data"]["result"].clone();+ if nv.is_object() {+ result["nativeViewport"] = json!({"path": nv["path"], "ok": nv["ok"], "bodies": nv["bodies"], "productType": nv["productType"], "document": nv["document"], "capturedAt": now_f(), "source": "app.activeViewport.saveAsImageFile through the add-in (no foreground, no window pixels)"});+ result["geometry"] = json!({"bodies": nv["bodies"], "productType": nv["productType"], "source": "Fusion API body count, independent of any capture"});+ } else {+ result["nativeViewport"] = json!({"ok": false, "error": r["error"].as_str().unwrap_or("add-in did not answer").chars().take(160).collect::<String>()});+ }+ }+ if args["screenshot"].as_bool() != Some(false) && !native_only { let shot = crate::small_verbs::screenshot_fusion(&json!({}), &ctx.caller); if shot["success"].as_bool() == Some(true) { let p = shot["savedTo"].as_str().unwrap_or("").to_string(); result["screenshot"] = json!(p);+ if let Some(frac) = png_dominant_fraction(&p) {+ result["windowPixels"] = json!({"dominantColorFraction": (frac * 1000.0).round() / 1000.0, "blank": frac >= 0.97,+ "note": "fraction of the window capture that is one colour; above 0.97 the capture shows no geometry (a GPU Design viewport under WGC can render blank or stale while the model is fine)"});+ } if args["imageAs"].as_str() == Some("base64") { if let Ok(raw) = std::fs::read(&p) { if raw.len() <= 4 * 1024 * 1024 { result["screenshotBase64"] = json!(crate::etiquette::b64_pub(&raw)); }@@ -596,6 +617,16 @@ pub fn fusion_state(ctx: &Ctx, args: &Value) -> Value { let mut hints: Vec<String> = Vec::new(); if let Some(b) = &busy { hints.push(format!("Main thread busy ({}, {}s) - add-in verbs are rejected right now; window/screenshot verbs still work.", b["command"].as_str().unwrap_or("?"), b["elapsedSeconds"])); } if !blocking.is_empty() { hints.push("A modal is up - see blockingDialogs for the classified cause + resolution.".into()); }+ if result["windowPixels"]["blank"].as_bool() == Some(true) {+ let bodies = result["geometry"]["bodies"].as_u64().unwrap_or(0);+ if result["nativeViewport"]["ok"].as_bool() == Some(true) {+ hints.push(format!("The window capture is blank (GPU Design viewport under WGC) while Fusion itself reports {bodies} bod{} and rendered nativeViewport.path: trust the native frame for geometry, never the window pixels, and do NOT foreground Fusion to fix a capture.", if bodies == 1 { "y" } else { "ies" }));+ } else {+ hints.push("The window capture is blank and the native viewport export did not answer; nothing here substantiates visible geometry. Re-call with screenshot:true once the add-in answers, or read geometry through fusion_inspect_bodies.".into());+ }+ } else if result["nativeViewport"]["ok"].as_bool() == Some(true) && result["nativeViewport"]["productType"].as_str() == Some("DesignProductType") {+ hints.push("nativeViewport.path is the frame Fusion itself rendered (activeViewport.saveAsImageFile); for a Design document prefer it over the window capture, which can lag the model.".into());+ } if hints.is_empty() { hints.push("Fusion is clear. The screenshot shows the current surface - this is the verb to render any fusion_show_* result (they open in the background by design).".into()); } result["_hint"] = json!(hints.join(" ")); result["output"] = json!(json!({"running": true, "activeDocument": result["activeDocument"], "mainThreadBusy": busy, "blockingDialogs": blocking, "screenshot": result["screenshot"]}).to_string());@@ -900,3 +931,49 @@ mod doc_name_tests { assert!(!super::doc_matches("ADS858~1", "0603WAF3300T5E")); } }++/// Fraction of a PNG's sampled pixels that share the dominant (quantised) colour. 1.0 is a+/// flat image; a Fusion window capture above ~0.97 shows chrome and nothing else (issue #84).+pub fn png_dominant_fraction(path: &str) -> Option<f64> {+ let f = std::fs::File::open(path).ok()?;+ let dec = png::Decoder::new(std::io::BufReader::new(f));+ let mut reader = dec.read_info().ok()?;+ let mut buf = vec![0u8; reader.output_buffer_size()];+ let info = reader.next_frame(&mut buf).ok()?;+ let bpp = match info.color_type { png::ColorType::Rgba => 4, png::ColorType::Rgb => 3, png::ColorType::GrayscaleAlpha => 2, png::ColorType::Grayscale => 1, _ => return None };+ let (w, h) = (info.width as usize, info.height as usize);+ if w == 0 || h == 0 { return None; }+ let step = ((w * h) / 20000).max(1);+ let mut counts: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();+ let mut total = 0u32;+ let mut i = 0usize;+ while i < w * h {+ let o = i * bpp;+ if o + bpp > buf.len() { break; }+ let (r, g, b) = if bpp >= 3 { (buf[o], buf[o + 1], buf[o + 2]) } else { (buf[o], buf[o], buf[o]) };+ let key = ((r as u32 >> 3) << 10) | ((g as u32 >> 3) << 5) | (b as u32 >> 3);+ *counts.entry(key).or_insert(0) += 1; total += 1;+ i += step;+ }+ let top = counts.values().copied().max().unwrap_or(0);+ if total == 0 { None } else { Some(top as f64 / total as f64) }+}++#[cfg(test)]+mod png_tests {+ #[test]+ fn flat_and_busy_images_are_told_apart() {+ let dir = std::env::temp_dir().join(format!("pngtest-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();+ for (name, busy) in [("flat.png", false), ("busy.png", true)] {+ let p = dir.join(name); let f = std::fs::File::create(&p).unwrap();+ let mut enc = png::Encoder::new(std::io::BufWriter::new(f), 64, 64); enc.set_color(png::ColorType::Rgb); enc.set_depth(png::BitDepth::Eight);+ let mut w = enc.write_header().unwrap();+ let mut data = vec![255u8; 64 * 64 * 3];+ if busy { for (i, px) in data.chunks_mut(3).enumerate() { px[0] = (i % 251) as u8; px[1] = (i * 7 % 241) as u8; px[2] = (i * 13 % 239) as u8; } }+ w.write_image_data(&data).unwrap();+ }+ assert!(super::png_dominant_fraction(&dir.join("flat.png").to_string_lossy()).unwrap() > 0.99);+ assert!(super::png_dominant_fraction(&dir.join("busy.png").to_string_lossy()).unwrap() < 0.5);+ let _ = std::fs::remove_dir_all(&dir);+ }+}
tools/check-naming.shtools/check-portable.shtools/promote.shtools/release.sh