← Commit history

Host the full source tree in the page repo (addin/, handlers/, skills/, examples/); README points to Files tab + repo clone instead of a source tarball

John Lauer ·f7d0c66709 ·4mo ago ·parent 141b84e
54 files changed +12894−15
.github/workflows/validate.yml+68.gitignore+8CLAUDE.md+93CONTRIBUTING.md+75LICENSE+21README.md+5−7SKILL.md+23addin/AdomBridge/AdomBridge.manifest+12addin/AdomBridge/AdomBridge.py+109addin/AdomBridge/commands/__init__.py+139addin/AdomBridge/commands/app_state.py+127addin/AdomBridge/commands/close_document.py+152addin/AdomBridge/commands/cloud_documents.py+1880addin/AdomBridge/commands/design_info.py+98addin/AdomBridge/commands/document_info.py+305addin/AdomBridge/commands/electron_view.py+139addin/AdomBridge/commands/electronics.py+1058addin/AdomBridge/commands/export.py+693addin/AdomBridge/commands/import_file.py+107addin/AdomBridge/commands/manufacturing.py+1185addin/AdomBridge/commands/modeling.py+68addin/AdomBridge/commands/open_electronics_file.py+543addin/AdomBridge/commands/open_lbr.py+183addin/AdomBridge/commands/parameters.py+134addin/AdomBridge/commands/screenshot.py+136addin/AdomBridge/commands/silkscreen_capture.py+308addin/AdomBridge/http_server.py+427addin/AdomBridge/resources/cam/jlcpcb-2layers.cam+245addin/AdomBridge/resources/cam/jlcpcb-4layers.cam+262addin/AdomBridge/resources/dru/Adom-InstaPCB-2-Layer.edru+165addin/AdomBridge/resources/dru/Adom-InstaPCB-4-Layer.edru+183addin/AdomBridge/resources/dru/AdomDefaults-jlcpcb-2layers-accurate.edru+165addin/AdomBridge/resources/dru/AdomDefaults-jlcpcb-4layers-accurate.edru+183addin/AdomBridge/resources/ulp/detect_layers.ulp+81describe.py+20−5eagle_lbr.py+270examples/jst_connector_generator.py+130examples/jst_ph_connector.py+115fusion_detect.py+290fusion_helper.py+382handlers/__init__.pyhandlers/close_fusion.py+335handlers/dialog_classify.py+97handlers/dismiss_recovery.py+332handlers/fusion_ui.py+706handlers/open_design.py+107install_addin.py+139server.py+7−3skills/fusion-aps-search/SKILL.md+93skills/fusion-aps-signin/SKILL.md+247skills/fusion-bridge-dev/SKILL.md+78skills/fusion-electronics/SKILL.md+78skills/fusion-onboarding/SKILL.md+81start.bat+7
.github/workflows/validate.ymladded+68
@@ -0,0 +1,68 @@+name: Validate bridge.json + version bump++# PR-only trigger. We don't run on direct pushes to main because the only+# pushes today are maintainer ones (already validated locally), and running+# on every push generates failed-run emails when the org's GH Actions+# billing isn't current. The real value of this workflow is gating+# community PRs before they reach main.++on:+  pull_request:+    branches: [main]++jobs:+  validate:+    runs-on: ubuntu-latest+    steps:+      - uses: actions/checkout@v4+        with:+          fetch-depth: 0  # need main + PR head for version-bump check++      - name: Validate bridge.json is parseable JSON+        run: |+          python3 -c "import json; d = json.load(open('bridge.json')); print(f'Parsed bridge.json: {d[\"name\"]} v{d[\"version\"]}')"++      - name: Check required fields exist+        run: |+          python3 - <<'PY'+          import json, sys+          d = json.load(open('bridge.json'))+          required = ['manifest_version', 'name', 'displayName', 'version', 'spawn', 'verbPrefixes']+          missing = [k for k in required if k not in d]+          if missing:+              print(f'ERROR: bridge.json missing required fields: {missing}')+              sys.exit(1)+          spawn = d['spawn']+          for k in ['kind', 'entrypoint']:+              if k not in spawn:+                  print(f'ERROR: bridge.json spawn missing required field: {k}')+                  sys.exit(1)+          # v1.8.31+: spawn.port should be 0 (dynamic) or warn+          if spawn.get('port', 0) != 0:+              print(f'WARN: spawn.port = {spawn["port"]} — dynamic port (0) is recommended for v1.8.31+. Hardcoded ports collide with sibling apps. See https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop-bridges')+          # Verb prefixes should end in underscore+          for p in d.get('verbPrefixes', []):+              if not p.endswith('_'):+                  print(f'WARN: verbPrefix "{p}" should end in underscore (convention)')+          print('bridge.json schema check passed')+          PY++      - name: Verify BRIDGE_VERSION matches bridge.json.version+        run: |+          BV=$(cat BRIDGE_VERSION | tr -d '[:space:]')+          JV=$(python3 -c "import json; print(json.load(open('bridge.json'))['version'])")+          if [ "$BV" != "$JV" ]; then+              echo "ERROR: BRIDGE_VERSION ($BV) != bridge.json version ($JV) — bump them together"+              exit 1+          fi+          echo "BRIDGE_VERSION matches bridge.json: $BV"++      - name: Verify version was bumped vs. main+        run: |+          MAIN_VER=$(git show origin/main:BRIDGE_VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.0")+          PR_VER=$(cat BRIDGE_VERSION | tr -d '[:space:]')+          if [ "$MAIN_VER" = "$PR_VER" ]; then+              echo "ERROR: BRIDGE_VERSION not bumped (still $MAIN_VER). Bump patch/minor/major in BRIDGE_VERSION AND bridge.json."+              exit 1+          fi+          echo "Version bumped: $MAIN_VER → $PR_VER"
.gitignoreadded+8
@@ -0,0 +1,8 @@+__pycache__/+*.pyc+adom-bridge-fusion-v1.5.1.zip+adom-bridge-fusion-source.tar.gz+adom-bridge-fusion.bundle+adom-bridge-fusion-v*.zip+adom-bridge-fusion-source.tar.gz+adom-bridge-fusion.bundle
CLAUDE.mdadded+93
@@ -0,0 +1,93 @@+# CLAUDE.md - adom-desktop-fusion-bridge (READ THIS, it is YOUR project)++This repo is **the Adom Desktop Fusion 360 bridge**. If you are an Adom AI touching anything+Fusion, **you own and maintain this code.** You write it, version it, publish it, and reinstall it.+Stop treating bridge problems as someone else's. (This file exists because an AI repeatedly forgot+all of this and filed its own bridge bugs as "adom-desktop" requests. Don't.)++## You vs adom-desktop (AD) core - the boundary++- **AD core owns:** the `adom-desktop` CLI, the relay / command passthrough, bridge lifecycle+  (`bridge_install` / streaming the bridge from the wiki into `…\bridges-cache\fusion360`), and the+  core `desktop_*` verbs. That's it. AD just **hosts and loads** this bridge.+- **YOU (this bridge) own:** every `fusion_*` verb and its response `_hint`s, the **APS** integration+  (auth/config/quota - see below), the **Fusion add-in** code AND **deploying it into Fusion**, the+  `SKILL.md` + bundled `skills/`, and the edit → publish → reinstall loop.+- So: a `fusion_*` verb behaves wrong, a hint is missing, APS misbehaves, the add-in is stale → it's+  **your code**, fix it here and republish. It is NOT an adom-desktop request.++## Map of what you manage++- `server.py` - bridge server / verb router (talks to AD's relay, proxies to the add-in). **On start+  it calls `install_addin()`** to deploy the add-in (see deploy below).+- `aps.py` - the **entire APS** implementation: PKCE OAuth, token store, server-indexed cloud search,+  never-charge cap. **All APS is yours** (the `fusion_aps_*` verbs + `fusion_search_cloud_files` /+  `fusion_walk_cloud_tree`). NOTE: port **8910 is AD's vestigial "native" APS** - NOT yours; this+  bridge uses **8917/8918/8920** and steers around it. The bare `aps_status` (8910) is that AD stub,+  not this bridge's `fusion_aps_status`.+- `addin/AdomBridge/` - the add-in that runs **inside Fusion**: `AdomBridge.py`, `http_server.py`,+  `commands/*.py` (cloud_documents, open_electronics_file, electronics, export, modeling,+  manufacturing, screenshot, document_info, parameters, …).+- `describe.py` - the `fusion_describe` self-describe catalog (AD's Verbs tab).+- `handlers/` - close / recovery / dialog-classify / UI / open_design.+- `skills/` - bundled skills you ship: `fusion-aps-search`, `fusion-aps-signin`, `fusion-bridge-dev`,+  `fusion-onboarding`. `resources/` - JLCPCB CAM jobs, Adom DRU rules, the layer-detect ULP.+- `bridge.json` + `BRIDGE_VERSION` - the manifest/version (keep in lockstep).++## How you DEPLOY when you change your code (VERIFIED by watching v1.5.1 ship, 2026-06-28)++Fusion loads the add-in from **`%APPDATA%\Roaming\Autodesk\Autodesk Fusion[ 360]\API\AddIns\AdomBridge\`**,+NOT from this repo or the cache. `install_addin.py` (`_sync_directory`) copies `addin/AdomBridge` ->+there. The exact, watched-it-happen release sequence (AFTER publishing to the wiki):++1. **`bridge_install {"manifestUrl": ".../adom-bridge-fusion-manifest.json"}`** - streams the new zip+   into the CACHE via `method: in_place_merge`. Updates the cache ONLY; it does NOT touch Fusion's+   AddIns dir. (Pass `"force": true` to re-merge when the version already bumped.)+   Do NOT manually `bridge_kill` - `bridge_install`'s own output says "AD reaps + respawns it from the+   new cache on the next call (no manual kill needed)," so the bridge SERVER (new server.py/describe.py)+   reloads automatically on the next verb. (Running `bridge_kill` here is pointless.)+2. **`fusion_stop`** (graceful; `fusion_kill` if it is wedged) - REQUIRED. Fusion holds the add-in files+   OPEN, so the sync canNOT overwrite them while Fusion runs. (This is why a bridge-server respawn alone+   does NOT update the add-in, and why `fusion_stop`+`fusion_start` by itself deploys nothing.)+3. Run the cache's **`install_addin.py`**: `shell_execute` -> `cd <cache>\fusion360 && python install_addin.py`.+   It prints `Updated: commands\cloud_documents.py ...` and removes stale `.bak`s - that is the add-in+   actually landing in Roaming.+4. **Verify by SHA256, never findstr.** `(Get-FileHash <Roaming>\commands\cloud_documents.py).Hash`+   must equal `sha256sum` of that same file unzipped from the published `v<ver>.zip`. findstr gives+   FALSE NEGATIVES on these files (UTF-8 box-drawing / dash chars break it) - it told me the reprimand+   was missing when the file was byte-identical to the release. Do not trust findstr; hash or+   `Get-Content -Raw` regex only.+5. **`fusion_start`** - Fusion now loads the new add-in. Confirm a `fusion_*` verb behaves new.++Memorize:+- `bridge_install` bumping `BRIDGE_VERSION` to the new version does NOT mean the running add-in changed.+  The version marker moves independently of the Roaming sync. Always hash-verify (step 5).+- The add-in sync only takes effect with **Fusion closed** (step 3). Skipping that is the #1 way a+  "successful" install leaves the OLD add-in running.+- **Never hand-edit the cache or the Roaming copy.** They get clobbered on the next stream/sync. Edit+  THIS source, publish, then run the sequence above.++## The publish/reinstall loop++See `skills/fusion-bridge-dev/SKILL.md` for the full ritual. Short form: edit here → bump+`BRIDGE_VERSION` + `bridge.json` together → `git commit` → **push GitHub backup** + **publish to the+wiki** (`wiki.adom.inc/adom/adom-desktop-fusion-bridge`, the canonical repo) → `bridge_install` on the+box → reap the old bridge instance + restart so the new code (and synced add-in) loads → anon-verify.+Wiki = canonical, GitHub = backup, keep in lockstep.++## Fusion electronics file hierarchy (the other thing you kept getting wrong)++A Fusion electronics design is a **parent/child chain of separate files**, each a distinct+`productType`. **ALWAYS open the PROJECT; never the schematic/.brd/3D directly.**++```+PROJECT        EcadDesignProductType    <- open THIS+  |- schematic   SchematicProductType+  |- board/.brd  BoardProductType+  |- 3D PCB      DesignProductType       (generated FROM the .brd - the LEAF; no editable board)+```++Opening a child gives an isolated/empty view (you can't pull a board out of the 3D). Same-named+copies in OTHER projects are usually 3D-model derivatives (`DesignProductType`), not the project.+The open verbs (`handle_open_cloud_file` / `handle_open_by_urn`) reprimand via `_hint` when you open+a non-`EcadDesignProductType` file - read it.
CONTRIBUTING.mdadded+75
@@ -0,0 +1,75 @@+# Contributing++This bridge is part of the [Adom Desktop](https://github.com/adom-inc/adom-desktop) bridge ecosystem. It lives in its own repo (split from the main monorepo in 2026-05) specifically so the community can fork and improve it without coordinating with Adom Desktop's release cycle.++## Two-way street++- **You can extend this bridge** — add support for a new app version, new verbs, new platforms — and PR it back here. We sync accepted PRs into adom-desktop's bundled copy on the next release.+- **You can fork it** if your changes diverge too much (e.g. private vendor SDK integration). The bridge.json schema is the contract; fork freely under MIT.++## How shipping works++1. **Develop locally.** Clone this repo, edit, test. The bridge runs alongside an installed [Adom Desktop](https://github.com/adom-inc/adom-desktop); use `adom-desktop bridge_install` with a local `file://` manifest URL to test your changes against a real GUI.+2. **Open a PR** against `main`. The CI in `.github/workflows/validate.yml` checks that `bridge.json` is valid + verb prefixes look right + the BRIDGE_VERSION was bumped vs. main.+3. **Maintainer reviews + merges.** No squash-merge — we preserve your authored commits.+4. **Maintainer tags + ships.** A tag `v<X.Y.Z>` triggers `release-bridge.sh` on a maintainer's box, which uploads the new zip + manifest to [the Adom Wiki](https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop-bridges). Anyone running Adom Desktop v1.7.19+ picks it up within 4 hours or on the next `adom-desktop refresh_bridges`.++We DON'T auto-ship from GitHub Actions today. Reason: `release-bridge.sh` needs an Adom Wiki API token, and we keep that secret on maintainer machines rather than in GH Actions secrets. The community trust model around auto-publishing community PRs to a shared wiki space is part of a separate effort ("Chunk D" in adom-desktop's TODO.md) — comment on an issue if you want to help design that.++## Bridge.json schema++See [the SDK guide on the Adom Wiki](https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop-bridges) for the authoritative schema. Quick summary:++- `manifest_version: 1`+- `name` — must match the directory name in adom-desktop's `plugins/` (when bundled)+- `version` — semver, bumped on every PR that ships+- `spawn.port: 0` — **always use 0** as of Adom Desktop v1.8.31+. Adom Desktop picks an OS-assigned ephemeral port and passes it via `--port <N>` to your bridge process. Don't hardcode.+- `spawn.healthEndpoint: "/status"` — path-only (recommended). Adom Desktop builds the URL using the runtime port.+- `verbPrefixes: ["<name>_"]` — the CLI verb prefixes this bridge owns.++## Versioning++- **Patch** (`0.8.3 → 0.8.4`): bug fixes, hardening, no schema or behavior changes.+- **Minor** (`0.8 → 0.9`): new verbs, new optional manifest fields, backward-compatible behavior changes.+- **Major** (`0.x → 1.0`, `1.x → 2.0`): breaking changes to verb shapes or removal of verbs. Coordinate with adom-desktop maintainers before submitting — we may need to wait for a desktop-app release that handles both versions.++Bump `BRIDGE_VERSION` AND `bridge.json`'s `"version"` field in the same commit — they must match.++## Testing locally++```bash+# 1. Clone this repo somewhere+git clone https://github.com/adom-inc/adom-bridge-<NAME>.git+cd adom-bridge-<NAME>++# 2. Install adom-desktop, then point it at this local manifest:+adom-desktop bridge_install '{"manifestUrl":"file:///path/to/adom-bridge-<NAME>/bridge.json"}'++# Note: file:// works only when manifestUrl is parsed by adom-desktop's CLI on the+# same machine that owns the bridge files. For Docker callers, push to a wiki staging+# area and use the wiki URL.++# 3. Run any verb the bridge defines:+adom-desktop <name>_<verb> '{"...":"..."}'++# 4. Run the SDK demo to verify install/list/invoke/uninstall lifecycle:+bash scripts/demo-thirdparty-bridge.sh  # in adom-desktop repo+```++## Why this bridge lives in its own repo++When adom-desktop was a monorepo, all bridges shipped on adom-desktop's NSIS release cycle — meaning a community contributor couldn't release a KiCad fix without waiting for an adom-desktop maintainer to cut a new desktop installer. As of 2026-05 the bridges live in independent repos so:++- Community can release at their own cadence+- Adom Desktop's installer ships a "known-good" snapshot but auto-updates from the wiki for newer versions+- A maintainer outside Adom Industries can take over a bridge (e.g. a KiCad 11 expert maintains the KiCad bridge) without commit access to adom-desktop itself++See [adom-desktop TODO.md → Chunks C/D](https://github.com/adom-inc/adom-desktop/blob/main/TODO.md) for the broader design.++## Code of conduct++Be kind. We're building this for engineers who need their CAD tools to work with AI without giving up local control of their files. If your PR is rejected, we'll explain why — and you can fork.++## License++MIT — see [LICENSE](LICENSE).
LICENSEadded+21
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Adom Industries, Inc. and contributors++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
README.md+5−7
@@ -93,16 +93,14 @@ Full reasoning + setup procedure live in the bundled skills: **`fusion-aps-searc  ## Source code -The **canonical source is this wiki page** (`https://wiki.adom.inc/adom/adom-desktop-fusion-bridge`) — a git-backed page. A **GitHub backup mirror** is kept in lockstep at [`adom-inc/adom-desktop-fusion-bridge`](https://github.com/adom-inc/adom-desktop-fusion-bridge) (private).+**This wiki page IS the git repository.** Every file you see in the [**Files tab**](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge) is the live source, browsable in place: `server.py`, `aps.py`, `describe.py`, the whole Fusion add-in under `addin/AdomBridge/`, the `handlers/`, the bundled `skills/`, all of it. No GitHub account needed. Two ways to get it: -The full source is also published right here on the wiki page — no GitHub account needed:+- **Browse it here:** open the [Files tab](https://wiki.adom.inc/adom/adom-desktop-fusion-bridge) and read any file right on the page.+- **Clone the whole tree:** `adom-wiki repo clone adom/adom-desktop-fusion-bridge` -| Download | What it is | How to use |-|---|---|---|-| [`adom-bridge-fusion-source.tar.gz`](https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion-source.tar.gz) | Clean source tree, no `.git`. | `tar xzf adom-bridge-fusion-source.tar.gz && cd adom-bridge-fusion/` |-| [`adom-bridge-fusion.bundle`](https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files/adom-bridge-fusion.bundle) | Git bundle with full commit history. | `git clone adom-bridge-fusion.bundle adom-bridge-fusion && cd adom-bridge-fusion && git log` |+A **GitHub backup mirror** is kept in lockstep at [adom-inc/adom-desktop-fusion-bridge](https://github.com/adom-inc/adom-desktop-fusion-bridge) (private), but this wiki page is canonical. -> The original home in `adom-inc/adom-desktop` → `plugins/fusion360/` is now a **frozen first-run fallback only** — stale, not canonical. See `STALE_SEED_README.md` there.+> The original home in `adom-inc/adom-desktop` -> `plugins/fusion360/` is a frozen first-run fallback only: stale, not canonical.  ## Platforms 
SKILL.md+23
@@ -14,6 +14,29 @@ adom-desktop fusion_<action> '<json_args>'  The leading `fusion_` routes to the Fusion plugin; `<action>` is the plugin-side name. +## [!] Electronics PCBs: ALWAYS open the PROJECT file (never the schematic / board / 3D)++A Fusion electronics design is NOT one file. It is a parent/child chain of FOUR files, each a+different `productType`:++```+PROJECT        EcadDesignProductType     <-- OPEN THIS ONE, ALWAYS+  |- schematic   SchematicProductType+  |- board/.brd  BoardProductType+  |- 3D PCB      DesignProductType        (generated from the .brd - the LEAF; opening it gives NO editable board)+```++- **Open the PROJECT** (`EcadDesignProductType`) with `fusion_open_cloud_file` / `fusion_open_by_urn`.+  Fusion then brings up its schematic + board as children; move between them with the schematic view,+  `fusion_show_2d_board`, and `fusion_show_3d_board`.+- **NEVER open the schematic, the .brd, or the 3D file directly** to "get the board." You get an+  isolated/empty view and waste time trying to derive a 2D board from the 3D. The 3D is the LEAF,+  generated FROM the .brd - it is not the board, and you cannot pull a board out of it.+- Same-named copies in OTHER projects (e.g. a drone assembly) are usually 3D-model derivatives+  (`DesignProductType`), NOT the project. Don't open those.+- `fusion_open_cloud_file` / `fusion_open_by_urn` REPRIMAND you in the response `_hint` when you open+  a child file instead of the project - read it and re-open the `EcadDesignProductType` project.+ ## ⚡ Fast cloud search (APS) — READ THIS FIRST for any "find/search/open a cloud design" task  To search the team's Fusion cloud files, use **`fusion_aps_search`**, NOT the legacy
addin/AdomBridge/AdomBridge.manifestadded+12
@@ -0,0 +1,12 @@+{+    "autodeskProduct": "Fusion",+    "type": "addin",+    "id": "com.adom.bridge",+    "author": "Adom Industries, Inc.",+    "description": {+        "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."+    },+    "version": "1.0.2",+    "runOnStartup": true,+    "supportedOS": "windows"+}
addin/AdomBridge/AdomBridge.pyadded+109
@@ -0,0 +1,109 @@+"""Adom Bridge — Fusion 360 Add-In.++Starts a localhost HTTP server on a background thread (port 8774).+Incoming commands are marshaled to the main Fusion thread via a CustomEvent,+so all Fusion API calls execute safely on the main thread.+"""++import adsk.core+import adsk.fusion+import traceback++# Module-level references kept alive for the add-in lifetime+_app = None+_ui = None+_custom_event = None+_event_handler = None+_http_thread = None++CUSTOM_EVENT_ID = "AdomBridgeCommandEvent"+++class CommandEventHandler(adsk.core.CustomEventHandler):+    """Handles custom events fired from the HTTP background thread.++    Each event's additionalInfo is a JSON string with {request_id, command, args}.+    The handler dispatches to the appropriate command function, then stores+    the result so the HTTP thread can return it.+    """++    def __init__(self):+        super().__init__()++    def notify(self, args: adsk.core.CustomEventArgs):+        try:+            import json+            from . import http_server+            from .commands import dispatch_command++            info = json.loads(args.additionalInfo)+            request_id = info["request_id"]+            command = info["command"]+            cmd_args = info.get("args", {})++            # Staleness check: if the HTTP waiter already timed out and+            # bailed, skip the work entirely. Saves main-thread time on+            # heavy exports whose caller already gave up.+            if not http_server.is_request_alive(request_id):+                adsk.core.Application.log(+                    f"AdomBridge: skipping stale request {request_id[:8]} ({command}) — HTTP waiter gone"+                )+                return++            try:+                result = dispatch_command(_app, command, cmd_args)+            except Exception as e:+                result = {"success": False, "error": f"Command error: {e}\n{traceback.format_exc()}"}++            http_server.set_result(request_id, result)++        except Exception:+            traceback.print_exc()+++def run(context):+    """Called by Fusion 360 when the add-in starts."""+    global _app, _ui, _custom_event, _event_handler, _http_thread++    try:+        _app = adsk.core.Application.get()+        _ui = _app.userInterface++        # Register the custom event for thread marshaling+        _custom_event = _app.registerCustomEvent(CUSTOM_EVENT_ID)+        _event_handler = CommandEventHandler()+        _custom_event.add(_event_handler)++        # Start the HTTP server on a background daemon thread+        from . import http_server+        _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)++        # Log to the Text Commands palette (non-blocking)+        adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)")++    except Exception:+        # Only use messageBox for errors — worth blocking to show the user+        if _ui:+            _ui.messageBox(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+++def stop(context):+    """Called by Fusion 360 when the add-in stops."""+    global _app, _ui, _custom_event, _event_handler, _http_thread++    try:+        # Stop HTTP server+        from . import http_server+        http_server.stop()++        # Unregister custom event+        if _app and _custom_event:+            _app.unregisterCustomEvent(CUSTOM_EVENT_ID)++        _custom_event = None+        _event_handler = None+        _http_thread = None++    except Exception:+        if _ui:+            _ui.messageBox(f"Adom Bridge stop error:\n{traceback.format_exc()}")
addin/AdomBridge/commands/__init__.pyadded+139
@@ -0,0 +1,139 @@+"""Command dispatch for the Adom Bridge Fusion 360 add-in."""++import adsk.core++from .app_state import handle_get_app_state+from .document_info import handle_document_info, handle_activate_document+from .export import (+    handle_export_step, handle_export_stl, handle_export_3mf,+    handle_export_f3d, handle_export_usdz, handle_export_fbx,+    handle_export_dxf, handle_export_dwg, handle_export_iges,+    handle_export_obj, handle_export_sat, handle_export_skp,+)+from .design_info import handle_get_design_info+from .electronics import (+    handle_electron_run,+    handle_execute_text_command,+    handle_export_source,+    handle_export_eagle_source,+    handle_open_electronics,+    handle_list_text_commands,+    handle_board_info,+)+from .electron_view import handle_electron_zoom, handle_electron_pan, handle_electron_select+from .open_lbr import handle_open_lbr, handle_export_lbr+from .import_file import handle_import_file+from .parameters import handle_get_parameters, handle_set_parameter+from .screenshot import handle_take_screenshot+from .close_document import handle_close_document, handle_close_all_documents+from .open_electronics_file import handle_open_schematic, handle_open_board, handle_show_3d_board, handle_show_2d_board, handle_show_schematic, handle_import_electronics+from .cloud_documents import (+    handle_save_to_cloud,+    handle_list_cloud_projects,+    handle_list_cloud_files,+    handle_delete_cloud_file,+    handle_create_cloud_folder,+    handle_open_cloud_file,+    handle_open_by_urn,+    handle_search_cloud_files,+    handle_export_cloud_file,+    handle_check_recovery,+    handle_walk_cloud_tree,+)+from .manufacturing import (+    handle_export_bom,+    handle_export_cpl,+    handle_export_gerbers,+    handle_set_design_rules,+    handle_export_board_image,+    handle_detect_layers,+)+from .silkscreen_capture import handle_take_silkscreen_screenshot+from .modeling import handle_run_modeling_script++COMMAND_HANDLERS = {+    # App state+    "get_app_state": handle_get_app_state,+    "document_info": handle_document_info,+    "activate_document": handle_activate_document,+    # 3D CAD commands+    "import_file": handle_import_file,+    "export_step": handle_export_step,+    "export_stl": handle_export_stl,+    "export_3mf": handle_export_3mf,+    "export_f3d": handle_export_f3d,+    "export_usdz": handle_export_usdz,+    "export_fbx": handle_export_fbx,+    "export_dxf": handle_export_dxf,+    "export_dwg": handle_export_dwg,+    "export_iges": handle_export_iges,+    "export_obj": handle_export_obj,+    "export_sat": handle_export_sat,+    "export_skp": handle_export_skp,+    "get_design_info": handle_get_design_info,+    "get_parameters": handle_get_parameters,+    "set_parameter": handle_set_parameter,+    "take_screenshot": handle_take_screenshot,+    # Electronics commands (EAGLE via Electron.run)+    "electron_run": handle_electron_run,+    "electron_zoom": handle_electron_zoom,+    "electron_pan": handle_electron_pan,+    "electron_select": handle_electron_select,+    "execute_text_command": handle_execute_text_command,+    "open_electronics": handle_open_electronics,+    "list_text_commands": handle_list_text_commands,+    "board_info": handle_board_info,+    "export_source": handle_export_source,+    "export_eagle_source": handle_export_eagle_source,+    # Library file commands (Document.Open / EXPORT SCRIPT)+    "open_lbr": handle_open_lbr,+    "export_lbr": handle_export_lbr,+    # Document management+    "close_document": handle_close_document,+    "close_all_documents": handle_close_all_documents,+    # Electronics file opening+    "open_schematic": handle_open_schematic,+    "open_board": handle_open_board,+    "show_3d_board": handle_show_3d_board,+    "show_2d_board": handle_show_2d_board,+    "show_schematic": handle_show_schematic,+    "import_electronics": handle_import_electronics,+    # Cloud document management+    "save_to_cloud": handle_save_to_cloud,+    "list_cloud_projects": handle_list_cloud_projects,+    "list_cloud_files": handle_list_cloud_files,+    "delete_cloud_file": handle_delete_cloud_file,+    "create_cloud_folder": handle_create_cloud_folder,+    "open_cloud_file": handle_open_cloud_file,+    "open_by_urn": handle_open_by_urn,+    "check_recovery": handle_check_recovery,+    "search_cloud_files": handle_search_cloud_files,+    "walk_cloud_tree": handle_walk_cloud_tree,+    "export_cloud_file": handle_export_cloud_file,+    # Manufacturing exports (Gerbers, BOM, CPL, DRC, images)+    "export_bom": handle_export_bom,+    "export_cpl": handle_export_cpl,+    "export_gerbers": handle_export_gerbers,+    "set_design_rules": handle_set_design_rules,+    "export_board_image": handle_export_board_image,+    "detect_layers": handle_detect_layers,+    # Silkscreen layer capture (isolated top/bottom PNG)+    "take_silkscreen_screenshot": handle_take_silkscreen_screenshot,+    # In-app parametric modeling (free; runs adsk.fusion in the live session)+    "run_modeling_script": handle_run_modeling_script,+}+++def dispatch_command(app: adsk.core.Application, command: str, args: dict) -> dict:+    """Dispatch a command to the appropriate handler.++    All handlers receive the Fusion Application object and an args dict.+    """+    handler = COMMAND_HANDLERS.get(command)+    if handler is None:+        return {+            "success": False,+            "error": f"Unknown add-in command: {command}",+            "_hint": f"Valid add-in commands: {', '.join(sorted(COMMAND_HANDLERS.keys()))}.",+        }+    return handler(app, args)
addin/AdomBridge/commands/app_state.pyadded+127
@@ -0,0 +1,127 @@+"""App state command — list open documents, active workspace, and Fusion state."""++import adsk.core+++def handle_get_app_state(app: adsk.core.Application, args: dict) -> dict:+    """Get the current state of Fusion 360: open documents, active workspace, etc.++    Returns information about every open document/tab, which one is active,+    the current workspace, and product type.+    """+    # Active workspace+    active_workspace = "unknown"+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+        if ws:+            active_workspace = ws.strip()+    except Exception:+        pass++    # Check if Electronics editor is active+    is_electronics = False+    try:+        app.executeTextCommand("Electron.run DISPLAY")+        is_electronics = True+    except Exception:+        pass++    # Active document+    active_doc_name = None+    try:+        doc = app.activeDocument+        if doc:+            active_doc_name = doc.name+    except Exception:+        pass++    # Active product type+    product_type = None+    try:+        product = app.activeProduct+        if product:+            product_type = product.productType+    except Exception:+        pass++    # All open documents+    documents = []+    try:+        for i in range(app.documents.count):+            doc = app.documents.item(i)+            doc_info = {+                "name": doc.name,+                "isActive": (doc.name == active_doc_name) if active_doc_name else False,+            }++            # documentType may not exist on all document types+            try:+                doc_info["type"] = _document_type_name(doc.documentType)+            except Exception:+                doc_info["type"] = "unknown"++            # isSaved may not be available on all document types+            try:+                doc_info["isSaved"] = doc.isSaved+            except Exception:+                pass++            # Try to get data file info (cloud save path)+            try:+                df = doc.dataFile+                if df:+                    doc_info["dataFile"] = {+                        "name": df.name,+                        "id": df.id,+                    }+                    try:+                        doc_info["dataFile"]["parentFolder"] = df.parentFolder.name+                    except Exception:+                        pass+            except Exception:+                pass++            documents.append(doc_info)+    except Exception as e:+        documents = [{"error": f"Failed to enumerate documents: {e}"}]++    # Build context-aware hint+    hints = []+    if is_electronics:+        hints.append("Electronics workspace active — fusion_electron_run, fusion_board_info, "+                      "fusion_export_source, fusion_export_eagle_source are available.")+    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.")+    hint = " ".join(hints)++    return {+        "success": True,+        "output": f"{len(documents)} document(s) open, active workspace: {active_workspace}",+        "data": {+            "activeWorkspace": active_workspace,+            "activeDocument": active_doc_name,+            "productType": product_type,+            "isElectronics": is_electronics,+            "documents": documents,+        },+        "_hint": hint,+    }+++def _document_type_name(doc_type) -> str:+    """Convert a DocumentType enum to a readable string."""+    type_map = {+        0: "FusionDesign",+        1: "FusionDesign",  # DesignDocumentType+        2: "Drawing",+        3: "Electronics",+        4: "PCB",+    }+    # The enum value might be an int or an enum object+    try:+        val = int(doc_type)+        return type_map.get(val, f"Unknown({val})")+    except (TypeError, ValueError):+        return str(doc_type)
addin/AdomBridge/commands/close_document.pyadded+152
@@ -0,0 +1,152 @@+"""Close document command — close a Fusion 360 document without the save dialog."""++import adsk.core+++def handle_close_document(app: adsk.core.Application, args: dict) -> dict:+    """Close a document in Fusion 360 without triggering the save dialog.++    Args (all optional):+        name: Name of the document to close. If omitted, closes the active document.+        save: If True, save before closing. Default False (discard changes, no dialog).++    This is essential for automation — a normal close triggers a modal save dialog+    that blocks the Fusion main thread and hangs the add-in until the user clicks.+    """+    target_name = args.get("name")+    save_changes = args.get("save", False)++    try:+        if target_name:+            # Find the document by name+            doc = None+            for i in range(app.documents.count):+                d = app.documents.item(i)+                if d.name == target_name:+                    doc = d+                    break+            if doc is None:+                # List available docs for a helpful error+                available = [app.documents.item(i).name for i in range(app.documents.count)]+                return {+                    "success": False,+                    "error": f"Document '{target_name}' not found. Open documents: {available}",+                    "_hint": "Call fusion_document_info to list open documents, or use fusion_close_document with no name to close the active document.",+                }+        else:+            # Close the active document+            doc = app.activeDocument+            if doc is None:+                return {+                    "success": False,+                    "error": "No active document to close.",+                    "_hint": "Call fusion_document_info to check open documents — nothing is currently active.",+                }++        doc_name = doc.name++        # Check if document has unsaved changes (for reporting)+        is_saved = True+        try:+            is_saved = doc.isSaved+        except Exception:+            pass++        # Close the document. Passing False skips the save dialog entirely.+        doc.close(save_changes)++        remaining = app.documents.count+        new_active = None+        try:+            if app.activeDocument:+                new_active = app.activeDocument.name+        except Exception:+            pass++        return {+            "success": True,+            "output": f"Closed '{doc_name}' (save={save_changes}, had_unsaved={'yes' if not is_saved else 'no'}). {remaining} document(s) remaining.",+            "data": {+                "closedDocument": doc_name,+                "savedBeforeClose": save_changes,+                "hadUnsavedChanges": not is_saved,+                "remainingDocuments": remaining,+                "activeDocument": new_active,+            },+            "_hint": f"{remaining} document(s) still open."+                     + (f" Active: '{new_active}'. " if new_active else " No active document. ")+                     + "Use fusion_get_app_state to check workspace, or fusion_open_cloud_file to open another document.",+        }++    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to close document: {e}",+            "_hint": "Retry with {\"save\": false} to discard changes, or call fusion_screenshot_fusion to check for a blocking Save dialog and dismiss it with fusion_send_key.",+        }+++def handle_close_all_documents(app: adsk.core.Application, args: dict) -> dict:+    """Close ALL open documents without save dialogs.++    Closes every open document/tab in Fusion without triggering any save+    dialogs. Essential before restarting Fusion to avoid the 'Recovered+    Documents' modal on next launch.++    Args (all optional):+        save: If True, save each document before closing. Default False.+    """+    save_changes = args.get("save", False)++    closed = []+    errors = []+    skipped = []++    # Snapshot the current document list ONCE. Do NOT loop on app.documents.count,+    # because Fusion may auto-create an Untitled blank doc whenever all docs close,+    # which turns `while count > 0` into an infinite loop and times out the bridge.+    try:+        snapshot = []+        for i in range(app.documents.count):+            d = app.documents.item(i)+            snapshot.append(d)+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to enumerate documents: {e}",+            "_hint": "Retry with {\"save\": false} to discard changes, or call fusion_screenshot_fusion to check for a blocking Save dialog and dismiss it with fusion_send_key.",+        }++    for doc in snapshot:+        try:+            doc_name = doc.name+        except Exception:+            doc_name = "<unknown>"+        try:+            # Skip Untitled blank docs — Fusion recreates them, closing is pointless+            has_data_file = False+            try:+                has_data_file = doc.dataFile is not None+            except Exception:+                has_data_file = False+            if not has_data_file and doc_name.lower().startswith("untitled"):+                skipped.append(doc_name)+                continue+            doc.close(save_changes)+            closed.append(doc_name)+        except Exception as e:+            errors.append(f"{doc_name}: {e}")+            # Keep going — don't abort the whole batch on one failure++    return {+        "success": len(errors) == 0,+        "output": f"Closed {len(closed)} document(s)"+                  + (f", skipped {len(skipped)} Untitled" if skipped else "")+                  + (f", {len(errors)} error(s)" if errors else ""),+        "data": {+            "closed": closed,+            "skipped": skipped if skipped else None,+            "errors": errors if errors else None,+            "remainingDocuments": app.documents.count,+        },+    }
addin/AdomBridge/commands/cloud_documents.pyadded+1880
@@ -0,0 +1,1880 @@+"""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()+    if not file_name:+        return {"success": False, "error": "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 {+            "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()+    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 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}}
addin/AdomBridge/commands/design_info.pyadded+98
@@ -0,0 +1,98 @@+"""Design info command — component tree, bodies, document info."""++import adsk.core+import adsk.fusion+++def handle_get_design_info(app: adsk.core.Application, args: dict) -> dict:+    """Get information about the active design.++    Returns component tree, bodies, user parameters, and document info.+    """+    product = app.activeProduct+    if not product or product.productType != "DesignProductType":+        return {+            "success": False,+            "error": "No active design open in Fusion 360",+            "_hint": "Call fusion_open_cloud_file to open a Design document, or fusion_show_3d_board if an electronics board is open, then retry.",+        }++    design = adsk.fusion.Design.cast(product)+    doc = app.activeDocument++    root = design.rootComponent+    components = _walk_component(root)++    # User parameters+    params = []+    for i in range(design.userParameters.count):+        param = design.userParameters.item(i)+        params.append({+            "name": param.name,+            "expression": param.expression,+            "value": param.value,+            "unit": param.unit,+        })++    return {+        "success": True,+        "data": {+            "document": {+                "name": doc.name if doc else "Untitled",+                "isSaved": doc.isSaved if doc else False,+            },+            "designType": "Parametric" if design.designType == adsk.fusion.DesignTypes.ParametricDesignType else "Direct",+            "components": components,+            "userParameters": params,+        },+    }+++def _walk_component(comp: adsk.fusion.Component) -> dict:+    """Recursively walk a component and its children."""+    bodies = []+    for i in range(comp.bRepBodies.count):+        body = comp.bRepBodies.item(i)+        bodies.append({+            "name": body.name,+            "isVisible": body.isVisible,+            "isSolid": body.isSolid,+            "volume_cm3": body.volume if body.isSolid else None,+        })++    # Also check mesh bodies (STL/3MF imports create these)+    mesh_bodies = []+    try:+        for i in range(comp.meshBodies.count):+            mb = comp.meshBodies.item(i)+            mesh_bodies.append({+                "name": mb.name,+                "isVisible": mb.isVisible,+            })+    except Exception:+        pass  # meshBodies may not exist in older API versions++    children = []+    for i in range(comp.occurrences.count):+        occ = comp.occurrences.item(i)+        children.append({+            "name": occ.name,+            "isVisible": occ.isVisible,+            "component": _walk_component(occ.component),+        })++    result = {+        "name": comp.name,+        "bodies": bodies,+        "children": children,+    }+    if mesh_bodies:+        result["meshBodies"] = mesh_bodies++    # Debug info: feature count tells us if there's actual content+    try:+        result["features"] = comp.features.count+    except Exception:+        pass++    return result
addin/AdomBridge/commands/document_info.pyadded+305
@@ -0,0 +1,305 @@+"""Document info and activation commands.++document_info — lightweight metadata about ALL open documents plus active doc details.+activate_document — switch to a specific open document tab by name or type.+"""++import adsk.core+++def _document_type_str(doc_type) -> str:+    """Convert a DocumentType enum to a human-readable string."""+    if doc_type is None:+        return "unknown"+    type_map = {+        0: "FusionDesign",+        1: "FusionDesign",+        2: "Drawing",+        3: "Electronics",+        4: "PCB",+    }+    try:+        val = int(doc_type)+        return type_map.get(val, f"Unknown({val})")+    except (TypeError, ValueError):+        return str(doc_type)+++def handle_document_info(app: adsk.core.Application, args: dict) -> dict:+    """Get metadata about all open documents and the active document.++    Returns every open document/tab with name, type, and active status,+    plus detailed cloud info for the active document. Uses only in-memory+    data — no cloud API calls.++    This is the command to use when Claude needs to know what tabs are open+    and which one is focused, without expensive cloud searches.+    """+    # Active workspace+    active_workspace = "unknown"+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+        if ws:+            active_workspace = ws.strip()+    except Exception:+        pass++    # Active document name (for isActive flag)+    active_doc_name = None+    active_doc = None+    try:+        active_doc = app.activeDocument+        if active_doc:+            active_doc_name = active_doc.name+    except Exception:+        pass++    # Enumerate ALL open documents+    open_documents = []+    try:+        for i in range(app.documents.count):+            doc = app.documents.item(i)+            doc_info = {+                "name": doc.name,+                "isActive": (doc.name == active_doc_name) if active_doc_name else False,+            }+            try:+                doc_info["documentType"] = _document_type_str(getattr(doc, 'documentType', None))+                doc_info["documentTypeRaw"] = str(getattr(doc, 'documentType', None))+            except Exception:+                doc_info["documentType"] = "unknown"++            try:+                doc_info["isSaved"] = doc.isSaved+            except Exception:+                pass++            # Cloud file info (lightweight — just the cached ID)+            try:+                df = doc.dataFile+                if df:+                    doc_info["cloudFileId"] = df.id+                    doc_info["isCloudDocument"] = True+                    try:+                        doc_info["cloudProjectName"] = df.parentProject.name+                    except Exception:+                        pass+                else:+                    doc_info["isCloudDocument"] = False+            except Exception:+                doc_info["isCloudDocument"] = False++            open_documents.append(doc_info)+    except Exception as e:+        open_documents = [{"error": f"Failed to enumerate documents: {e}"}]++    # Detailed info for the active document+    active_detail = {}+    if active_doc:+        try:+            active_detail["documentName"] = active_doc.name+        except Exception:+            pass++        try:+            df = active_doc.dataFile+            if df:+                active_detail["isCloudDocument"] = True+                try:+                    active_detail["cloudFileId"] = df.id+                except Exception:+                    pass+                try:+                    active_detail["documentVersion"] = df.versionNumber+                except Exception:+                    pass+                try:+                    active_detail["cloudProjectName"] = df.parentProject.name+                except Exception:+                    pass+                try:+                    # Build folder path+                    folder = df.parentFolder+                    parts = []+                    depth = 0+                    while folder and depth < 10:+                        parts.append(folder.name)+                        try:+                            folder = folder.parentFolder+                        except Exception:+                            break+                        depth += 1+                    if parts:+                        parts.reverse()+                        active_detail["cloudFolderPath"] = "/".join(parts)+                except Exception:+                    pass+            else:+                active_detail["isCloudDocument"] = False+        except Exception:+            active_detail["isCloudDocument"] = False++        try:+            active_detail["isSaved"] = active_doc.isSaved+        except Exception:+            pass++    # Product type+    try:+        product = app.activeProduct+        if product:+            active_detail["productType"] = product.productType+    except Exception:+        pass++    data = {+        "activeWorkspace": active_workspace,+        "activeDocument": active_doc_name,+        "openDocuments": open_documents,+        "openDocumentCount": len(open_documents),+        **active_detail,+    }++    return {+        "success": True,+        "output": f"{len(open_documents)} document(s) open, "+                  f"active: {active_doc_name or '(none)'}, "+                  f"workspace: {active_workspace}",+        "data": data,+        "_hint": "Use fusion_get_app_state to check workspace type. "+                 "For exports: fusion_export_gerbers, fusion_export_bom, fusion_export_cpl, "+                 "fusion_export_source (.fsch/.fbrd), fusion_export_eagle_source (.sch/.brd). "+                 "Use fusion_activate_document to switch tabs.",+    }+++def handle_activate_document(app: adsk.core.Application, args: dict) -> dict:+    """Switch to a specific open document tab by name or type.++    Searches all open documents and activates the matching one. This is+    essential for automation — Claude can switch between schematic, board,+    and library tabs without asking the user to click.++    Args:+        name: Document name to activate (case-insensitive substring match).+        documentType: Document type to activate — "Electronics", "PCB",+                      "FusionDesign", "Drawing". If both name and type are+                      given, both must match.++    If multiple documents match, activates the first match and lists all+    matches in the response so Claude can refine.+    """+    target_name = args.get("name", "").strip().lower()+    target_type = args.get("documentType", "").strip().lower()+    target_data_file = args.get("dataFileName", "").strip()  # EXACT match on dataFile.name++    if not target_name and not target_type and not target_data_file:+        return {+            "success": False,+            "error": "Specify 'name' (substring) or 'dataFileName' (exact cloud-file name) "+                     "or 'documentType' (Electronics, PCB, FusionDesign, Drawing).",+            "_hint": "Call fusion_document_info to list open documents by index, or fusion_open_cloud_file to open a new document, then retry.",+        }++    # Find matching documents+    matches = []+    try:+        for i in range(app.documents.count):+            doc = app.documents.item(i)+            name_match = True+            type_match = True+            datafile_match = True++            if target_data_file:+                df_name = None+                try:+                    df_name = doc.dataFile.name if doc.dataFile else None+                except Exception:+                    df_name = None+                datafile_match = (df_name == target_data_file)++            if target_name:+                name_match = target_name in doc.name.lower()++            if target_type:+                doc_type_str = _document_type_str(getattr(doc, 'documentType', None)).lower()+                doc_type_raw = str(getattr(doc, 'documentType', None)).lower()+                type_match = (target_type in doc_type_str or+                              target_type in doc_type_raw)++            if name_match and type_match and datafile_match:+                matches.append(doc)+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to enumerate documents: {e}",+            "_hint": "Call fusion_get_app_state to check if Fusion is in a usable state; a modal dialog may be blocking.",+        }++    if not matches:+        # List what IS open for debugging+        available = []+        try:+            for i in range(app.documents.count):+                doc = app.documents.item(i)+                available.append({+                    "name": doc.name,+                    "type": _document_type_str(getattr(doc, 'documentType', None)),+                })+        except Exception:+            pass++        return {+            "success": False,+            "error": f"No open document matching name='{target_name or '*'}' "+                     f"type='{target_type or '*'}'",+            "_hint": "Call fusion_document_info to list open documents by index, or fusion_open_cloud_file to open a new document, then retry.",+            "data": {+                "openDocuments": available,+                "hint": "Use fusion_document_info to see all open documents.",+            },+        }++    # Activate the first match+    target_doc = matches[0]+    try:+        activated = target_doc.activate()+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to activate '{target_doc.name}': {e}",+            "_hint": "Call fusion_get_app_state to check if Fusion is in a usable state; a modal dialog may be blocking.",+            "data": {"hint": "The document may be in a state that prevents activation."},+        }++    # Query workspace after activation+    new_workspace = "unknown"+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+        if ws:+            new_workspace = ws.strip()+    except Exception:+        pass++    response = {+        "success": True,+        "output": f"Activated '{target_doc.name}' — workspace: {new_workspace}",+        "data": {+            "activatedDocument": target_doc.name,+            "documentType": _document_type_str(getattr(target_doc, 'documentType', None)),+            "activeWorkspace": new_workspace,+        },+    }++    # If multiple matches, list them all so Claude can refine+    if len(matches) > 1:+        response["data"]["allMatches"] = [+            {"name": m.name, "type": _document_type_str(getattr(m, 'documentType', None))}+            for m in matches+        ]+        response["data"]["hint"] = (+            f"{len(matches)} documents matched — activated the first one. "+            "Use a more specific name to target a different one."+        )++    return response
addin/AdomBridge/commands/electron_view.pyadded+139
@@ -0,0 +1,139 @@+"""Video-friendly view control for the Fusion Electronics (EAGLE) editor.++One bridge call = a whole smooth motion, because the loop runs HERE inside the add-in+(tight timing + a repaint per frame), not as N round-trips from the bridge server.++  fusion_electron_zoom   - smooth zoom in/out (or fit)+  fusion_electron_pan    - smooth pan/zoom to a board-coordinate box+  fusion_electron_select - select a part by name (or x/y) and surface its properties++All EAGLE motion is `app.executeTextCommand("Electron.run WINDOW ...")`; each frame is+forced to render with `Electron.ImmediateRepaint /full` (the 2D analogue of vp.refresh()).+"""++import time+import adsk.core++# Last view box we set, in board mm (x1, y1, x2, y2). Lets pan interpolate from where+# the view actually is. None until the first pan establishes it.+_view_box = None+++def _run(app, eagle_cmd):+    return app.executeTextCommand(f"Electron.run {eagle_cmd}")+++def _repaint(app):+    try:+        app.executeTextCommand("Electron.ImmediateRepaint /full")+    except Exception:+        pass+++def handle_electron_zoom(app: adsk.core.Application, args: dict) -> dict:+    """Smoothly zoom the schematic / 2D board view.++    Args:+        fit:          bool - zoom to fit the whole sheet/board (ignores factor).+        factor:       float - total zoom multiple (>1 = in, <1 = out). Default 2.0.+        steps:        int   - frames over which to ramp (smoothness). Default 16.+        frameDelayMs: int   - ms between frames. Default 35.+    """+    if args.get("fit"):+        _run(app, "WINDOW FIT")+        _repaint(app)+        return {"success": True, "output": "Zoomed to fit."}++    factor = float(args.get("factor", 2.0))+    steps = max(1, int(args.get("steps", 16)))+    delay = float(args.get("frameDelayMs", 35)) / 1000.0+    per = factor ** (1.0 / steps)  # geometric per-frame step -> perceptually even+    for _ in range(steps):+        _run(app, f"WINDOW {per:.5f}")+        _repaint(app)+        adsk.doEvents()+        time.sleep(delay)+    return {+        "success": True,+        "output": f"Zoomed {'in' if factor > 1 else 'out'} {factor}x over {steps} frames.",+    }+++def handle_electron_pan(app: adsk.core.Application, args: dict) -> dict:+    """Smoothly pan + zoom the view to frame a board-coordinate box.++    Coords are board millimetres (get part positions from fusion_board_info). The view+    animates from wherever it last was (tracked) to the target box.++    Args:+        x1, y1, x2, y2: required floats - the target rectangle (board mm).+        steps:          int   - frames. Default 16.+        frameDelayMs:   int   - ms between frames. Default 35.+    """+    global _view_box+    try:+        tx1, ty1 = float(args["x1"]), float(args["y1"])+        tx2, ty2 = float(args["x2"]), float(args["y2"])+    except (KeyError, TypeError, ValueError):+        return {"success": False, "error": "Need x1,y1,x2,y2 (board mm). Get part positions from fusion_board_info."}++    steps = max(1, int(args.get("steps", 16)))+    delay = float(args.get("frameDelayMs", 35)) / 1000.0++    if _view_box is None:+        _run(app, f"WINDOW ({tx1:.3f} {ty1:.3f}) ({tx2:.3f} {ty2:.3f})")+        _repaint(app)+        _view_box = (tx1, ty1, tx2, ty2)+        return {"success": True, "output": "Framed region (no animation - first view set; later pans animate).",+                "data": {"box": _view_box}}++    sx1, sy1, sx2, sy2 = _view_box+    for i in range(1, steps + 1):+        t = i / steps+        x1 = sx1 + (tx1 - sx1) * t+        y1 = sy1 + (ty1 - sy1) * t+        x2 = sx2 + (tx2 - sx2) * t+        y2 = sy2 + (ty2 - sy2) * t+        _run(app, f"WINDOW ({x1:.3f} {y1:.3f}) ({x2:.3f} {y2:.3f})")+        _repaint(app)+        adsk.doEvents()+        time.sleep(delay)+    _view_box = (tx1, ty1, tx2, ty2)+    return {"success": True,+            "output": f"Panned to ({tx1:.1f},{ty1:.1f})-({tx2:.1f},{ty2:.1f}) over {steps} frames.",+            "data": {"box": _view_box}}+++def handle_electron_select(app: adsk.core.Application, args: dict) -> dict:+    """Select a part (by reference designator, or by x/y board coords) and read its+    properties - the way clicking a part in the editor reveals the properties panel.++    Args:+        name (or component): str - reference designator (e.g. "R1"). Cleanest.+        x, y:                floats - board-mm point to click/select (alternative).+        properties:          bool - also return the selected device's info. Default true.+    """+    name = args.get("name") or args.get("component")+    try:+        if name:+            sel = app.executeTextCommand(f"Electron.SelectbyName {name}")+        elif "x" in args and "y" in args:+            app.executeTextCommand(f"Electron.LeftButtonDown {args['x']} {args['y']}")+            sel = app.executeTextCommand(f"Electron.Select {args['x']} {args['y']}")+        else:+            return {"success": False, "error": "Provide {name} (reference designator) or {x,y} (board mm)."}+    except Exception as e:+        return {"success": False, "error": f"Select failed: {e}"}++    _repaint(app)+    info = ""+    if args.get("properties", True):+        try:+            info = app.executeTextCommand("Electron.sch_get_deviceinfo")+        except Exception:+            pass+    return {+        "success": True,+        "output": f"Selected {name or (str(args.get('x')) + ',' + str(args.get('y')))}.",+        "data": {"selection": str(sel)[:300], "deviceInfo": str(info)[:600]},+    }
addin/AdomBridge/commands/electronics.pyadded+1058
@@ -0,0 +1,1058 @@+"""Electronics commands — run EAGLE commands via Electron.run text command.++Fusion 360's Electronics workspace (inherited from EAGLE) has no Python API,+but we can use `app.executeTextCommand('Electron.run <cmd>')` to send EAGLE+editor commands into the electronics workspace.++IMPORTANT: The user must be in an Electronics editor (schematic, board, or library)+for Electron.run commands to work. The "Electronics Design" overview/container+workspace does NOT count — the user must have a schematic or board open and active.++Workspace hierarchy:+  "Design"              — Normal 3D CAD (Electron.run fails)+  "Electronics Design"  — Container/overview (Electron.run fails)+  "Schematic Editor"    — Schematic editing (Electron.run works)+  "Board Layout"        — PCB layout editing (Electron.run works)+  "Electronics Library" — Library editing (Electron.run works)++Commands that BLOCK (open dialogs — avoid from automation):+  WRITE               — Opens Save As dialog+  ADD                 — Opens component picker dialog+  SHOW <name>         — Opens interactive highlight mode+  GRID (no params)    — Opens grid settings dialog+  EDIT <new>.sym      — Opens "Create new?" confirmation dialog++Safe automation patterns:+  SCRIPT '<path>.scr' — Runs batch commands from file (non-interactive)+  EXPORT SCRIPT       — Exports library/schematic to .scr file (for verification)+  DISPLAY             — Shows/hides layers (non-interactive)+  WINDOW FIT          — Zoom to fit+  EDIT <existing>.sym — Opens existing symbol (no dialog)+  SET CONFIRM YES     — In .scr: suppresses create-new confirmations+"""++import adsk.core+import glob+import os+import re+import tempfile+++def _get_fusion_log_path() -> str | None:+    """Find the latest Fusion 360 log file."""+    log_dir = os.path.join(+        os.environ.get("LOCALAPPDATA", ""),+        "Autodesk", "Autodesk Fusion 360",+    )+    # Find user-specific log dir (e.g. X3G293QFLPJERKGZ/logs/)+    for entry in os.listdir(log_dir):+        candidate = os.path.join(log_dir, entry, "logs")+        if os.path.isdir(candidate):+            logs = sorted(glob.glob(os.path.join(candidate, "AppLogFile*.log")),+                          key=os.path.getmtime, reverse=True)+            if logs:+                return logs[0]+    return None+++def _get_log_line_count(log_path: str) -> int:+    """Get current line count of a log file (fast — seeks to end)."""+    try:+        with open(log_path, "r", encoding="utf-8", errors="replace") as f:+            return sum(1 for _ in f)+    except Exception:+        return 0+++def _check_log_for_alerts(log_path: str, after_line: int) -> list[str]:+    """Check Fusion log for 'New Alert message' entries after a given line number.++    Returns list of alert message texts (the error toast content).+    """+    alerts = []+    try:+        with open(log_path, "r", encoding="utf-8", errors="replace") as f:+            for i, line in enumerate(f, 1):+                if i <= after_line:+                    continue+                if "New Alert message" in line:+                    # Extract: "Tooltip: <msg>, Short Message: <msg>"+                    m = re.search(r"Short Message:\s*(.+?)(?:,\s*Long Message:|$)", line)+                    if m:+                        alerts.append(m.group(1).strip())+                    else:+                        # Fallback: grab everything after "Tooltip: "+                        m2 = re.search(r"Tooltip:\s*(.+?)(?:,|$)", line)+                        if m2:+                            alerts.append(m2.group(1).strip())+    except Exception:+        pass+    return alerts+++def _extract_export_path(eagle_cmd: str) -> str | None:+    """Extract the output file path from an EAGLE EXPORT command.++    EAGLE EXPORT commands use single-quoted paths:+      EXPORT NETLIST 'C:/tmp/netlist.txt'+      EXPORT IMAGE 'C:/tmp/board.png' 300+      EXPORT PARTLIST 'C:/tmp/parts.txt'++    Returns the unquoted path, or None if not found.+    """+    # Match single-quoted path+    m = re.search(r"'([^']+)'", eagle_cmd)+    if m:+        return m.group(1)+    # Fallback: look for unquoted path-like tokens (with / or \ and an extension)+    parts = eagle_cmd.strip().split()+    for part in parts[2:]:  # Skip "EXPORT" and subcommand+        cleaned = part.strip("'\"")+        if ("/" in cleaned or "\\" in cleaned) and "." in cleaned:+            return cleaned+    return None+++def _get_workspace_info(app: adsk.core.Application) -> dict:+    """Get current workspace info and whether Electronics Electron editor is active."""+    try:+        active_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+    except Exception:+        active_ws = "unknown"++    is_electron = False+    try:+        app.executeTextCommand("Electron.run DISPLAY")+        is_electron = True+    except Exception:+        pass++    # Get document info+    doc_info = None+    try:+        doc = app.activeDocument+        if doc:+            doc_info = {"name": doc.name, "type": str(doc.documentType)}+    except Exception:+        pass++    # Get product type+    product_type = None+    try:+        product = app.activeProduct+        if product:+            product_type = product.productType+    except Exception:+        pass++    info = {+        "activeWorkspace": active_ws.strip() if active_ws else "unknown",+        "isElectronics": is_electron,+    }+    if doc_info:+        info["document"] = doc_info+    if product_type:+        info["productType"] = product_type+    return info+++def _get_recent_operations(app: adsk.core.Application, count: int = 5) -> list:+    """Get recent Fusion operations log via Diagnostics.RecentOperations."""+    try:+        result = app.executeTextCommand(f"Diagnostics.RecentOperations {count}")+        if result:+            lines = [l.strip() for l in result.strip().split("\r\n") if l.strip()]+            # Filter to just command begin/end, skip DLL loads+            return [l for l in lines if l.startswith("Command ")]+        return []+    except Exception:+        return []+++def _query_post_command_state(app: adsk.core.Application, eagle_cmd: str, ops_before: list) -> dict:+    """Query Fusion state after an Electron.run command to return meaningful feedback.++    Uses Diagnostics.RecentOperations to diff what actually happened, plus+    workspace/document state for context.+    """+    import time+    time.sleep(0.3)  # Brief pause for command to take effect++    state = {}++    # Always include workspace info+    try:+        active_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+        state["activeWorkspace"] = active_ws.strip() if active_ws else "unknown"+    except Exception:+        state["activeWorkspace"] = "unknown"++    # Active document+    try:+        doc = app.activeDocument+        if doc:+            state["activeDocument"] = doc.name+    except Exception:+        pass++    # Diff operations log — what commands fired as a result?+    ops_after = _get_recent_operations(app, 10)+    if ops_before and ops_after:+        # Find new operations that weren't in the before snapshot+        new_ops = []+        for op in ops_after:+            if op not in ops_before:+                new_ops.append(op)+        if new_ops:+            state["fusionOperations"] = new_ops[:5]  # Cap at 5 to keep response size sane++    # Parse command to provide targeted feedback+    cmd_parts = eagle_cmd.strip().split(None, 1)+    cmd_verb = cmd_parts[0].upper() if cmd_parts else ""+    cmd_upper = eagle_cmd.strip().upper()++    # Command-specific hints and extra queries+    if cmd_verb == "DISPLAY":+        state["commandType"] = "layer_visibility"+        state["hint"] = "DISPLAY command executed. Use fusion_take_screenshot to verify layer changes visually."++    elif cmd_verb == "WINDOW":+        state["commandType"] = "view_control"+        state["hint"] = "Zoomed to fit all content in view." if "FIT" in cmd_upper else "View position/zoom changed."++    elif cmd_verb == "EDIT":+        target = cmd_parts[1] if len(cmd_parts) > 1 else ""+        state["commandType"] = "edit"+        ext_map = {".sym": "symbol", ".pac": "package", ".dev": "device"}+        for ext, editor in ext_map.items():+            if target.endswith(ext):+                state["hint"] = f"Opened {editor} editor for: {target}"+                state["editorType"] = editor+                break+        else:+            state["hint"] = f"Opened editor for: {target}"++    elif cmd_verb == "RATSNEST":+        state["commandType"] = "ratsnest"+        state["hint"] = "Ratsnest (airwires) recalculated."++    elif cmd_verb == "BOARD":+        state["commandType"] = "view_switch"+        state["hint"] = "Requested switch to board layout. Verify with a screenshot."++    elif cmd_verb == "SET":+        state["commandType"] = "setting"+        state["hint"] = f"Setting changed: {eagle_cmd.strip()}"++    elif cmd_verb == "SCRIPT":+        state["commandType"] = "script"+        state["hint"] = f"Script executed: {cmd_parts[1] if len(cmd_parts) > 1 else '(unknown)'}"++    elif cmd_verb == "USE":+        state["commandType"] = "library_load"+        state["hint"] = f"Library load requested: {cmd_parts[1] if len(cmd_parts) > 1 else '(unknown)'}"++    elif cmd_verb in ("NAME", "VALUE", "SMASH", "MOVE", "ROTATE", "DELETE", "COPY", "GROUP",+                       "ROUTE", "RIPUP", "WIRE", "CIRCLE", "RECT", "POLYGON", "TEXT", "ARC",+                       "PAD", "SMD", "VIA", "HOLE", "PIN", "LABEL", "NET", "BUS", "JUNCTION"):+        state["commandType"] = "design_edit"+        state["hint"] = f"Design edit command executed: {cmd_verb}"++    elif cmd_verb == "EXPORT":+        state["commandType"] = "export"+        # Try to verify the export actually produced a file+        # Parse output path from command: EXPORT <TYPE> <path> [options]+        export_parts = eagle_cmd.strip().split()+        export_path = None+        if len(export_parts) >= 3:+            # The path is typically the 3rd token (after EXPORT <TYPE>)+            candidate = export_parts[2]+            # Handle quoted paths+            if candidate.startswith(("'", '"')):+                quote = candidate[0]+                rest = eagle_cmd.strip().split(quote)+                if len(rest) >= 3:+                    export_path = rest[1]+            else:+                export_path = candidate++        if export_path:+            import os+            if os.path.exists(export_path):+                fsize = os.path.getsize(export_path)+                state["hint"] = f"Export succeeded: {export_path} ({fsize} bytes)"+                state["exportedFile"] = export_path+                state["exportedFileSize"] = fsize+            else:+                state["hint"] = (+                    f"Export command ran but output file NOT found at: {export_path}. "+                    "The EAGLE command may have failed silently — check Fusion's error toast or operations log."+                )+                state["exportFailed"] = True+                state["expectedPath"] = export_path+        else:+            state["hint"] = f"Export command executed: {eagle_cmd.strip()}"++    else:+        state["commandType"] = "generic"+        state["hint"] = f"Command executed: {eagle_cmd.strip()}. Use fusion_take_screenshot to verify the result visually."++    # If no Fusion operations fired, the command likely failed silently.+    # EAGLE's Electron.run never returns errors — failures only show as+    # toast dialogs on the user's screen that are invisible to the API.+    # We check Fusion's error log for alert toasts (see handle_electron_run),+    # but log writes are async so errors may appear after our check window.+    if "fusionOperations" not in state or not state["fusionOperations"]:+        state["possibleFailure"] = True+        state["hint"] = (+            f"WARNING: No Fusion operations detected after '{cmd_verb}'. "+            f"The command likely failed silently. Check the 'alertErrors' field "+            f"in this response — if populated, it contains the actual Fusion error "+            f"message captured from the log. If 'alertErrors' is empty, the error "+            f"may not have been logged yet; take a screenshot with "+            f"fusion_take_screenshot to see the error toast in Fusion's UI. "+            f"Original hint: {state.get('hint', '')}"+        )++    return state+++def handle_electron_run(app: adsk.core.Application, args: dict) -> dict:+    """Execute an EAGLE command in the Electronics workspace via Electron.run.++    Requires an active Electronics editor (schematic, board, or library).+    Returns workspace state + Fusion operations log diff after execution.++    Args:+        args: Dict with:+            - command: The EAGLE command to execute (required).+              Examples: "EDIT resistor.sym", "USE mylib.lbr", "WINDOW FIT"+    """+    eagle_cmd = args.get("command", "")+    if not eagle_cmd:+        return {"success": False, "error": "No command specified. Provide an EAGLE command string."}++    # Check we're in Electronics before executing+    ws_info = _get_workspace_info(app)+    if not ws_info["isElectronics"]:+        return {+            "success": False,+            "error": f"Not in Electronics editor (current: {ws_info['activeWorkspace']}). "+                     "Open a schematic or board in Fusion 360's Electronics workspace first.",+            "_hint": "Call fusion_open_schematic {\"filePath\":\"...\"}, fusion_open_board {\"filePath\":\"...\"}, or fusion_open_lbr {\"filePath\":\"...\"} to enter the Electronics editor, then retry.",+            "data": {+                **ws_info,+                "hint": "Use fusion_open_schematic, fusion_open_board, or fusion_open_lbr to open an electronics file first.",+            },+        }++    # Parse the first word as the command verb for validation and routing.+    cmd_verb = eagle_cmd.strip().split()[0].upper() if eagle_cmd.strip() else ""++    # Block commands that don't work in Fusion's Electronics workspace or that+    # pop up error dialogs on the user's screen with no useful result.+    BLOCKED_COMMANDS = {+        "SCHEMATIC": "Use fusion_show_2d_board or fusion_electron_run '{\"command\": \"BOARD\"}' to switch between schematic/board views. "+                     "The SCHEMATIC command doesn't exist in Fusion's EAGLE integration.",+        "ADD": "Opens an interactive component picker dialog that blocks automation. "+               "Place components through the Fusion UI instead.",+        "SHOW": "Opens interactive highlight mode that blocks automation. "+                "Use fusion_board_info to query component/net data instead.",+        "CHANGE": "Opens interactive change mode that blocks automation.",+        "MOVE": "Opens interactive move mode — requires clicking to place. "+                "Not suitable for CLI automation. Use the Fusion API to move components programmatically.",+    }++    # WRITE always opens a blocking "Version Description" dialog on cloud docs,+    # even when a path argument is provided. Tested 2026-04-10 — WRITE <path>+    # still triggers the dialog. Block unconditionally.+    if cmd_verb == "WRITE":+        return {+            "success": False,+            "error": "Command 'WRITE' is blocked — it opens a blocking 'Version Description' save dialog "+                     "on cloud documents, even when a path argument is provided (tested 2026-04-10). "+                     "Use fusion_export_source for .fsch/.fbrd export, or fusion_export_eagle_source for .sch/.brd.",+            "_hint": "For Fusion-format source: fusion_export_source {\"outputPath\":\"C:/out/file.fbrd\"}. "+                     "For EAGLE-format source: fusion_export_eagle_source {\"outputPath\":\"C:/out/file.brd\"}.",+            "data": {+                "command": eagle_cmd,+                "blockedVerb": "WRITE",+                "activeWorkspace": ws_info["activeWorkspace"],+            },+        }++    if cmd_verb in BLOCKED_COMMANDS:+        return {+            "success": False,+            "error": f"Command '{cmd_verb}' is blocked for automation: {BLOCKED_COMMANDS[cmd_verb]}",+            "_hint": f"'{cmd_verb}' opens an interactive dialog. Use fusion_board_info / fusion_save_to_cloud / the Fusion API alternatives instead of this EAGLE command.",+            "data": {+                "command": eagle_cmd,+                "blockedVerb": cmd_verb,+                "hint": BLOCKED_COMMANDS[cmd_verb],+                "activeWorkspace": ws_info["activeWorkspace"],+            },+        }++    # Auto-suppress overwrite/confirmation dialogs for EXPORT commands.+    # Without this, EAGLE shows a Qt "Overwrite?" dialog that blocks the+    # add-in thread and is invisible to the CLI/AI — freezing everything.+    export_target_path = None+    if cmd_verb == "EXPORT":+        try:+            app.executeTextCommand("Electron.run SET CONFIRM YES")+        except Exception:+            pass  # Best effort — don't block if SET fails++        # Extract the output file path from the EXPORT command.+        # EAGLE EXPORT syntax: EXPORT <type> '<path>' [options]+        # Paths are typically single-quoted: EXPORT NETLIST '/path/to/file.txt'+        export_target_path = _extract_export_path(eagle_cmd)+        if export_target_path and os.path.exists(export_target_path):+            try:+                os.remove(export_target_path)+            except PermissionError:+                return {+                    "success": False,+                    "error": f"Cannot overwrite '{export_target_path}' — file is locked by another process.",+                    "_hint": "Choose a different outputPath (e.g., append a timestamp) and retry the EXPORT command.",+                    "data": {+                        "command": eagle_cmd,+                        "filePath": export_target_path,+                        "hint": "The target file exists and is locked. Close any program using it, "+                                "or choose a different output path.",+                        "nextSteps": [+                            f"Delete or rename '{export_target_path}' manually",+                            "Or use a different output path in the EXPORT command",+                        ],+                    },+                }+            except Exception as e:+                return {+                    "success": False,+                    "error": f"Cannot remove existing file '{export_target_path}': {e}",+                    "data": {+                        "command": eagle_cmd,+                        "filePath": export_target_path,+                        "hint": "Pre-delete failed. Use a different output path to avoid the overwrite dialog.",+                    },+                }++    # Snapshot operations log and Fusion error log BEFORE execution+    ops_before = _get_recent_operations(app, 10)+    log_path = _get_fusion_log_path()+    log_line_before = _get_log_line_count(log_path) if log_path else 0++    try:+        raw_result = app.executeTextCommand(f"Electron.run {eagle_cmd}")+    except Exception as e:+        return {+            "success": False,+            "error": f"Electron.run failed: {e}",+            "_hint": "Check the active workspace with fusion_execute_text_command {\"command\": \"DebugCommands.ActiveWorkspace\"} — EAGLE commands need the right editor (schematic vs board vs library). Call fusion_open_schematic / fusion_open_board / fusion_open_lbr to switch.",+            "data": {+                "command": eagle_cmd,+                "hint": "The command may be invalid or require a specific editor context (schematic vs board vs library).",+            },+        }++    # Query state after execution — includes ops log diff+    post_state = _query_post_command_state(app, eagle_cmd, ops_before)++    # Check Fusion's error log for alert messages that appeared after the command.+    # These are the toast errors shown in the lower-right of Fusion's UI that+    # Electron.run silently swallows — the only way to detect them is via the log.+    # Fusion writes to the log asynchronously, so we poll with increasing delays.+    alert_errors = []+    if log_path:+        import time+        for _attempt in range(4):+            time.sleep(0.3 if _attempt == 0 else 0.5)+            alert_errors = _check_log_for_alerts(log_path, log_line_before)+            if alert_errors:+                break++    # Detect export failures (command ran but output file not created)+    succeeded = True+    if post_state.get("exportFailed"):+        succeeded = False++    # If Fusion logged alert errors, the command failed+    if alert_errors:+        succeeded = False+        post_state["alertErrors"] = alert_errors+        post_state["hint"] = (+            f"EAGLE error: {alert_errors[0]}. "+            f"Original hint: {post_state.get('hint', '')}"+        )++    # For EXPORT commands, verify the output file exists and report size+    export_data = {}+    if cmd_verb == "EXPORT" and export_target_path:+        import time+        time.sleep(0.5)  # Give EAGLE a moment to flush to disk+        if os.path.exists(export_target_path):+            fsize = os.path.getsize(export_target_path)+            export_data = {+                "exportedFile": export_target_path,+                "fileSizeBytes": fsize,+                "fileSizeKB": fsize // 1024,+            }+            succeeded = True  # Override exportFailed if file actually exists+        else:+            succeeded = False+            export_data = {+                "exportedFile": export_target_path,+                "exportFailed": True,+                "hint": f"EXPORT command ran but file was not created at '{export_target_path}'. "+                        "This EXPORT subcommand may not be supported in the current editor context.",+            }++    result = {+        "success": succeeded,+        "error": alert_errors[0] if alert_errors else "",+        "output": post_state.get("hint", f"Executed: {eagle_cmd}"),+        "data": {+            "command": eagle_cmd,+            "rawResult": raw_result if raw_result else "",+            "alertErrors": alert_errors,+            **post_state,+            **export_data,+        },+    }++    # If we suspect failure but didn't catch a log error, hint the AI+    # that errors from Electron.run are async — the real error may appear+    # in the next command's alertErrors or in a screenshot.+    if post_state.get("possibleFailure") and not alert_errors:+        result["data"]["asyncErrorHint"] = (+            "Electron.run is fire-and-forget — errors only appear as toast "+            "dialogs in Fusion's UI. The error log was checked but nothing "+            "appeared yet. Run fusion_take_screenshot to see the current "+            "Fusion state and check for error toasts."+        )++    return result+++def handle_open_electronics(app: adsk.core.Application, args: dict) -> dict:+    """Check Electronics workspace status and optionally open a file.++    PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file.++    Args:+        args: Dict with:+            - type: "status" to just check (default). Other values reserved+                   for future use (workspace switching handled by bridge server).+            - filePath: Optional path to a .sch, .brd, or .lbr file to open+                       (only works if already in Electronics editor).+    """+    file_path = args.get("filePath", "")+    ws_info = _get_workspace_info(app)++    # If already in electronics, handle file opening or return status+    if ws_info["isElectronics"]:+        if file_path:+            try:+                result = app.executeTextCommand(f"Electron.run EDIT '{file_path}'")+                return {+                    "success": True,+                    "output": f"Opened {file_path} in Electronics workspace.",+                    "data": {"result": result if result else "", **ws_info},+                }+            except Exception as e:+                return {+                    "success": False,+                    "error": f"Failed to open {file_path}: {e}",+                    "_hint": "Verify the file exists on the Windows host, then try fusion_open_schematic or fusion_open_board with the correct filePath.",+                }+        return {+            "success": True,+            "output": "Electronics editor is active. Electron.run commands will work.",+            "data": ws_info,+        }++    # Not in Electronics — report status+    return {+        "success": True,+        "output": f"Not in Electronics editor (current workspace: {ws_info['activeWorkspace']}).",+        "data": ws_info,+    }+++def handle_export_source(app: adsk.core.Application, args: dict) -> dict:+    """Export the active electronics document as .fsch, .fbrd, or .flbr source file.++    Uses Fusion's Document.CopyToDesktop text command, which exports the+    currently active document in its native format based on the file extension.++    PREREQUISITES — the caller MUST set up the correct view before calling:++    To export .fbrd (board):+      1. Open the .fprj:  fusion_open_cloud_file {"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", ...}+      2. Enter board view: fusion_show_2d_board+      3. Export:           fusion_export_source {"outputPath": "C:/out/MyDesign.fbrd"}++    To export .fsch (schematic):+      4. Switch to schematic: fusion_electron_run {"command": "EDIT .s1"}+      5. Export:              fusion_export_source {"outputPath": "C:/out/MyDesign.fsch"}++    To export .flbr (library):+      1. Open the .lbr:   fusion_open_lbr {"filePath": "C:/path/to/lib.lbr"}+      2. Export:           fusion_export_source {"outputPath": "C:/out/MyLib.flbr"}++    IMPORTANT GOTCHAS:+    - You MUST open the .fprj file first, NOT .fbrd/.fsch directly+    - You MUST call show_2d_board before exporting .fbrd (the 3D board view won't work)+    - To switch between board and schematic: use EDIT .s1 (schematic) or EDIT .brd (board)+    - The active workspace MUST be "Board Layout" for .fbrd or "Schematic Editor" for .fsch+    - If the wrong workspace is active, the file will not be created+    - After opening cloud file, wait for the document to fully load before exporting+    - Always screenshot after open to check for blocking dialogs before exporting++    Args:+        args: Dict with:+            - outputPath: Required. Full path including extension (.fsch, .fbrd, or .flbr).+              The extension determines the export format.+    """+    import os++    output_path = args.get("outputPath", "").strip()+    if not output_path:+        return {+            "success": False,+            "error": "Missing required arg: outputPath",+            "_hint": "Provide outputPath with extension .fsch, .fbrd, or .flbr. "+                     "For plain EAGLE format (.sch/.brd), use fusion_export_eagle_source instead.",+        }++    # Validate extension+    ext = os.path.splitext(output_path)[1].lower()+    valid_exts = {".fsch", ".fbrd", ".flbr"}+    if ext not in valid_exts:+        return {+            "success": False,+            "error": f"Invalid extension '{ext}'. Must be one of: {', '.join(sorted(valid_exts))}",+            "_hint": "Use .fsch for schematic source, .fbrd for board source, .flbr for library source. "+                     "For plain EAGLE format (.sch/.brd), use fusion_export_eagle_source instead.",+        }++    # Ensure output directory exists+    out_dir = os.path.dirname(output_path)+    if out_dir:+        os.makedirs(out_dir, exist_ok=True)++    # Normalize path for Fusion (use forward slashes)+    fusion_path = output_path.replace("\\", "/")++    try:+        result = app.executeTextCommand(f"Document.CopyToDesktop {fusion_path}")+    except Exception as e:+        return {+            "success": False,+            "error": f"Document.CopyToDesktop failed: {e}",+            "_hint": "Ensure the correct workspace is active: Board Layout for .fbrd, Schematic Editor for .fsch, Electronics Library for .flbr. Call fusion_show_2d_board or fusion_electron_run {\"command\":\"EDIT .s1\"} to switch.",+        }++    # Verify file was created+    # Fusion may use the exact path or normalize it+    check_path = output_path.replace("/", "\\")+    if not os.path.exists(check_path) and not os.path.exists(output_path):+        return {+            "success": False,+            "error": f"Command ran but file not found at {output_path}. "+                     "Make sure the correct editor is active (Board Layout for .fbrd, "+                     "Schematic Editor for .fsch).",+            "_hint": "Call fusion_show_2d_board (for .fbrd) or fusion_electron_run {\"command\":\"EDIT .s1\"} (for .fsch) to activate the right workspace, then retry.",+            "data": {"commandResult": result.strip() if result else ""},+        }++    actual_path = check_path if os.path.exists(check_path) else output_path+    size_bytes = os.path.getsize(actual_path)++    # Get active document context+    doc_name = ""+    workspace = ""+    try:+        doc = app.activeDocument+        if doc:+            doc_name = doc.name+        workspace = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    return {+        "success": True,+        "output": f"Exported {ext} source ({size_bytes:,} bytes) to {output_path}",+        "data": {+            "savedTo": output_path.replace("\\", "/"),+            "sizeBytes": size_bytes,+            "format": ext.lstrip("."),+            "activeDocument": doc_name,+            "activeWorkspace": workspace,+        },+    }+++def handle_export_eagle_source(app: adsk.core.Application, args: dict) -> dict:+    """Export the active electronics document as plain EAGLE .sch or .brd XML.++    Fusion's .fsch/.fbrd files are ZIP containers with a real EAGLE .sch/.brd+    XML file inside at `electron.BlobParts/ExtFile.*.{sch,brd}`. This command:+      1. Exports the .fsch/.fbrd via Document.CopyToDesktop (same as fusion_export_source)+      2. Extracts the inner EAGLE XML from the ZIP+      3. Writes it to outputPath as a plain EAGLE file+      4. Cleans up the intermediate .fsch/.fbrd++    The resulting file is valid EAGLE XML (`<?xml ...><eagle version="9.7.0">...`)+    that can be opened by standalone EAGLE, KiCad (via import), or parsed by any+    XML tool.++    PREREQUISITES — same as fusion_export_source:+      Board (.brd):  must be in "Board Layout" / "PCB Editor" workspace+      Schematic (.sch): must be in "Schematic Editor" workspace++    Args:+        args: Dict with:+            - outputPath: Required. Full path ending in .sch or .brd.+    """+    import tempfile+    import zipfile++    output_path = args.get("outputPath", "").strip()+    if not output_path:+        return {"success": False, "error": "Missing required arg: outputPath"}++    ext = os.path.splitext(output_path)[1].lower()+    valid_exts = {".sch": ".fsch", ".brd": ".fbrd"}+    if ext not in valid_exts:+        return {+            "success": False,+            "error": f"Invalid extension '{ext}'. Must be .sch or .brd.",+            "_hint": "For Fusion-format source (.fsch/.fbrd/.flbr), use fusion_export_source instead.",+        }++    fusion_ext = valid_exts[ext]  # .sch -> .fsch, .brd -> .fbrd++    # Export to a temp .fsch/.fbrd first+    tmp_dir = tempfile.mkdtemp(prefix="adom-eagle-")+    tmp_fusion_path = os.path.join(tmp_dir, f"temp{fusion_ext}")+    fusion_path = tmp_fusion_path.replace("\\", "/")++    try:+        result = app.executeTextCommand(f"Document.CopyToDesktop {fusion_path}")+    except Exception as e:+        return {+            "success": False,+            "error": f"Document.CopyToDesktop failed: {e}",+            "_hint": "Ensure the correct workspace is active: PCB Editor / Board Layout for .brd, "+                     "Schematic Editor for .sch. Use fusion_show_2d_board or "+                     "fusion_electron_run {\"command\":\"EDIT .s1\"} to switch.",+        }++    # Check if the file was created+    check_path = tmp_fusion_path.replace("/", "\\")+    actual_tmp = None+    for p in [check_path, tmp_fusion_path]:+        if os.path.exists(p):+            actual_tmp = p+            break++    if not actual_tmp:+        return {+            "success": False,+            "error": f"Document.CopyToDesktop ran but intermediate {fusion_ext} file not found.",+            "_hint": "Ensure the correct workspace is active: PCB Editor / Board Layout for .brd, "+                     "Schematic Editor for .sch.",+        }++    # Extract the EAGLE XML from the ZIP container+    eagle_data = None+    inner_name = None+    try:+        with zipfile.ZipFile(actual_tmp, "r") as zf:+            for name in zf.namelist():+                if name.endswith(ext):  # Find the .sch or .brd inside+                    eagle_data = zf.read(name)+                    inner_name = name+                    break+    except Exception as e:+        # Clean up temp+        try:+            os.remove(actual_tmp)+            os.rmdir(tmp_dir)+        except Exception:+            pass+        return {+            "success": False,+            "error": f"Failed to read {fusion_ext} ZIP container: {e}",+        }++    # Clean up temp file+    try:+        os.remove(actual_tmp)+        os.rmdir(tmp_dir)+    except Exception:+        pass++    if not eagle_data:+        return {+            "success": False,+            "error": f"No {ext} file found inside {fusion_ext} ZIP container.",+            "_hint": "The Fusion container format may have changed. Use fusion_export_source "+                     "to get the raw .fsch/.fbrd and inspect with a ZIP tool.",+        }++    # Verify it looks like EAGLE XML+    header = eagle_data[:100].decode("utf-8", errors="replace")+    if "<eagle" not in header and "<?xml" not in header:+        return {+            "success": False,+            "error": f"Extracted file does not appear to be EAGLE XML. "+                     f"First 100 bytes: {header!r}",+        }++    # Write to output path+    out_dir = os.path.dirname(output_path)+    if out_dir:+        os.makedirs(out_dir, exist_ok=True)++    with open(output_path, "wb") as f:+        f.write(eagle_data)++    size_bytes = len(eagle_data)++    # Get context+    doc_name = ""+    workspace = ""+    try:+        doc = app.activeDocument+        if doc:+            doc_name = doc.name+        workspace = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    return {+        "success": True,+        "output": f"Exported EAGLE {ext} ({size_bytes:,} bytes) to {output_path}",+        "data": {+            "savedTo": output_path.replace("\\", "/"),+            "sizeBytes": size_bytes,+            "format": ext.lstrip("."),+            "innerZipEntry": inner_name,+            "activeDocument": doc_name,+            "activeWorkspace": workspace,+        },+        "_hint": "This is a plain EAGLE XML file — parseable by standalone EAGLE, "+                 "KiCad import, or any XML tool. For Fusion-format source, use "+                 "fusion_export_source instead.",+    }+++def handle_execute_text_command(app: adsk.core.Application, args: dict) -> dict:+    """Execute any Fusion 360 text command directly.++    This is a lower-level command that runs `app.executeTextCommand()` without+    any wrapper. Useful for workspace info, debugging, or non-Electron commands.++    Unlike Electron.run, many text commands DO return meaningful results+    (e.g., DebugCommands.ActiveWorkspace returns the workspace name).++    Args:+        args: Dict with:+            - command: The full text command to execute (required).+    """+    text_cmd = args.get("command", "")+    if not text_cmd:+        return {"success": False, "error": "No command specified."}++    try:+        result = app.executeTextCommand(text_cmd)++        data = {"command": text_cmd, "result": result.strip() if result else ""}++        # Add workspace context for common debug queries+        if not text_cmd.startswith("DebugCommands.ActiveWorkspace"):+            try:+                ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+                data["activeWorkspace"] = ws.strip() if ws else "unknown"+            except Exception:+                pass++        try:+            doc = app.activeDocument+            if doc:+                data["activeDocument"] = doc.name+        except Exception:+            pass++        return {+            "success": True,+            "output": result.strip() if result else f"Executed: {text_cmd} (no output)",+            "data": data,+        }+    except Exception as e:+        return {+            "success": False,+            "error": f"Text command failed: {e}",+            "_hint": "Call fusion_list_text_commands to discover valid commands, or verify the active workspace supports this command.",+        }+++def handle_list_text_commands(app: adsk.core.Application, args: dict) -> dict:+    """List available Fusion 360 text commands matching a filter.++    Args:+        args: Dict with:+            - filter: Optional filter string (default: "Electron").+    """+    filter_str = args.get("filter", "Electron")++    try:+        result = app.executeTextCommand("TextCommands.List")+        if result:+            lines = result.split("\n")+            matching = [l for l in lines if filter_str.lower() in l.lower()]+            return {+                "success": True,+                "output": f"Found {len(matching)} commands matching '{filter_str}'",+                "data": {+                    "commands": matching[:50],+                    "total": len(matching),+                },+            }+        return {+            "success": True,+            "output": "TextCommands.List returned no output",+            "data": {"commands": []},+        }+    except Exception as e:+        return {+            "success": False,+            "error": f"TextCommands.List failed: {e}",+            "_hint": "Ensure an Electronics workspace is active — call fusion_open_schematic, fusion_open_board, or fusion_open_lbr first, then retry.",+        }+++def handle_board_info(app: adsk.core.Application, args: dict) -> dict:+    """Get structured board data from the current PCB layout.++    Returns XML with copper traces, pads, component placements (name, x, y,+    rotation), net names, layer setup, board thickness, DRC violations, and+    library packages used. Only works when a .brd board is open in PCB Editor.++    This is much richer than a screenshot — the AI can read exact coordinates,+    net connectivity, and component positions.+    """+    # Electron.boardInfo requires an output file path+    board_info_path = os.path.join(tempfile.gettempdir(), "adom_board_info.xml")++    try:+        app.executeTextCommand(f"Electron.boardInfo {board_info_path}")+    except Exception as e:+        err_msg = str(e)+        if "not PCB2d document" in err_msg.lower() or "not pcb" in err_msg.lower():+            ws = "unknown"+            try:+                ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+            except Exception:+                pass+            return {+                "success": False,+                "error": f"No board open (current workspace: {ws}). Open a .brd file first with fusion_open_board.",+                "_hint": "Call fusion_open_board {\"filePath\":\"...\"} or fusion_show_2d_board to enter Board Layout, then retry.",+                "data": {"activeWorkspace": ws},+            }+        return {+            "success": False,+            "error": f"boardInfo failed: {e}",+            "_hint": "Ensure a .brd file is open in Board Layout — call fusion_show_2d_board first, then retry.",+        }++    # Read the output file (Fusion writes UTF-16 LE BOM)+    result = None+    for enc in ("utf-16", "utf-8-sig", "utf-8"):+        try:+            with open(board_info_path, "r", encoding=enc) as f:+                result = f.read()+            break+        except (UnicodeDecodeError, UnicodeError):+            continue+    if result is None:+        try:+            with open(board_info_path, "rb") as f:+                raw = f.read()+            return {"success": False, "error": f"Failed to decode boardInfo (first bytes: {raw[:20].hex()})"}+        except Exception as e:+            return {"success": False, "error": f"Failed to read boardInfo output: {e}"}++    if not result:+        return {"success": False, "error": "boardInfo returned no data. Is a .brd board open?"}++    # Parse the XML to extract a summary for the AI+    import xml.etree.ElementTree as ET+    summary = {}+    try:+        root = ET.fromstring(result)+        board = root.find("g")+        if board is not None:+            summary["name"] = board.get("name", "")+            summary["unit"] = board.get("unit", "mm")+            summary["boardThickness"] = board.get("boardThickness", "")+            summary["layerSetup"] = board.get("layersetup", "")++        # Extract component placements from <libraries>+        components = []+        for lib_group in root.iter("g"):+            lib = lib_group.get("library")+            pkg = lib_group.get("package")+            if lib and pkg:+                for comp in lib_group.findall("g"):+                    name = comp.get("name")+                    if name:+                        components.append({+                            "name": name,+                            "library": lib,+                            "package": pkg,+                            "x": comp.get("x"),+                            "y": comp.get("y"),+                            "rotation": comp.get("rot", "R0"),+                            "locked": comp.get("LOCKED", "false") == "true",+                        })+        summary["components"] = components+        summary["componentCount"] = len(components)++        # Extract net names from copper traces+        nets = set()+        for copper in root.iter("copper"):+            net_name = copper.get("name", "")+            if net_name:+                nets.add(net_name)+        summary["nets"] = sorted(nets)+        summary["netCount"] = len(nets)++        # Extract DRC issues+        drc = root.find(".//drc")+        if drc is not None:+            overlaps = []+            for overlap in drc.findall(".//overlap"):+                for signal in overlap.findall("signal"):+                    overlaps.append(signal.get("name", ""))+            if overlaps:+                summary["drcOverlaps"] = overlaps++    except ET.ParseError:+        # If XML parsing fails, just return the raw result+        pass++    return {+        "success": True,+        "output": f"Board info: {summary.get('name', 'unknown')}, "+                  f"{summary.get('componentCount', 0)} components, "+                  f"{summary.get('netCount', 0)} nets",+        "data": {+            "summary": summary,+            "rawXml": result,  # Full XML for detailed analysis if needed+        },+    }
addin/AdomBridge/commands/export.pyadded+693
@@ -0,0 +1,693 @@+"""Export commands — STEP, STL, 3MF, F3D, FBX.++All 3D export commands require an active Fusion Design (3D PCB view or a CAD+design document). Switch to 3D PCB view with fusion_show_3d_board before+exporting from an electronics project.+"""++import adsk.core+import adsk.fusion+import os+import time+from pathlib import Path+++# Shared hint constants — reused across every export handler.+_HINT_NO_OUTPUT_PATH = (+    "Provide an outputPath arg like {\"outputPath\": \"C:\\\\path\\\\out.step\"}. "+    "The path must include the file extension matching the export format."+)+_HINT_FORMAT_UNAVAILABLE = (+    "This format isn't available in this Fusion build. "+    "Use fusion_export_step or fusion_export_stl as a fallback."+)+_HINT_EXPORT_FAILED = (+    "If this persists, call fusion_screenshot_fusion to check for a blocking dialog, "+    "then fusion_send_key with 'escape' or 'enter' to dismiss it. "+    "Wrong-workspace errors: call fusion_show_3d_board first."+)+_HINT_FILE_LOCKED = (+    "Close the file in any viewer (Blender, Windows Explorer preview, etc.), "+    "or call fusion_close_document first if it's open in Fusion, then retry."+)+++def _get_active_design(app: adsk.core.Application):+    """Get the active Fusion design, or None."""+    product = app.activeProduct+    if product and product.productType == "DesignProductType":+        return adsk.fusion.Design.cast(product)+    return None+++def _no_design_error():+    """Standard error when no active design is available."""+    return {+        "success": False,+        "error": "No active design open in Fusion 360.",+        "_hint": "If you're in PCB Editor (2D board), call fusion_show_3d_board to switch to 3D view. Otherwise call fusion_open_cloud_file to open a Design document, then retry.",+        "data": {+            "hint": "3D exports require a Design product. If you're in PCB Editor (2D board), "+                    "switch to 3D view first with fusion_show_3d_board.",+            "recoverySteps": [+                "Run fusion_show_3d_board to switch to 3D PCB view",+                "Or open a .f3d design document",+                "Then retry the export command",+            ],+        },+    }+++def _export_success(fmt: str, output_path: str, extra_msg: str = "") -> dict:+    """Standard success response for 3D exports."""+    size = os.path.getsize(output_path) if os.path.exists(output_path) else 0+    return {+        "success": True,+        "output": f"Exported {fmt} to {output_path}",+        "message": f"{fmt} file exported ({size // 1024} KB). "+                   f"{extra_msg}"+                   "Use pull_file to transfer to Docker.",+        "data": {+            "outputPath": output_path,+            "format": fmt.lower(),+            "fileSizeKB": size // 1024,+            "nextSteps": [+                "Use pull_file to transfer the file back to Docker",+                "Export other formats: fusion_export_step, fusion_export_stl, fusion_export_3mf, fusion_export_f3d, fusion_export_fbx",+            ],+        },+    }+++def handle_export_step(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a STEP file (.step).++    Industry-standard CAD interchange format. Best for mechanical CAD+    interoperability (SolidWorks, CATIA, Creo, etc.).++    Args:+        outputPath: Full path for the output .step file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    export_mgr = design.exportManager+    options = export_mgr.createSTEPExportOptions(output_path)+    result = export_mgr.execute(options)++    if result:+        return _export_success("STEP", output_path,+                               "Industry-standard CAD interchange — opens in any CAD tool. ")+    return {"success": False, "error": "STEP export failed", "_hint": _HINT_EXPORT_FAILED}+++def handle_export_stl(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as an STL file (.stl).++    Mesh-based format for 3D printing and visualization.++    Args:+        outputPath: Full path for the output .stl file (required).+        refinement: "low", "medium", or "high" (default: "medium").+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    root = design.rootComponent+    export_mgr = design.exportManager+    options = export_mgr.createSTLExportOptions(root, output_path)++    refinement = args.get("refinement", "medium").lower()+    refinement_map = {+        "low": adsk.fusion.MeshRefinementSettings.MeshRefinementLow,+        "medium": adsk.fusion.MeshRefinementSettings.MeshRefinementMedium,+        "high": adsk.fusion.MeshRefinementSettings.MeshRefinementHigh,+    }+    options.meshRefinement = refinement_map.get(+        refinement, adsk.fusion.MeshRefinementSettings.MeshRefinementMedium+    )++    result = export_mgr.execute(options)++    if result:+        return _export_success("STL", output_path,+                               f"Mesh refinement: {refinement}. Good for 3D printing. ")+    return {"success": False, "error": "STL export failed", "_hint": _HINT_EXPORT_FAILED}+++def handle_export_3mf(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a 3MF file (.3mf).++    Modern 3D printing format — supports color, materials, and multi-body.++    Args:+        outputPath: Full path for the output .3mf file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    root = design.rootComponent+    export_mgr = design.exportManager+    options = export_mgr.createC3MFExportOptions(root, output_path)+    result = export_mgr.execute(options)++    if result:+        return _export_success("3MF", output_path,+                               "Modern 3D printing format with color/material support. ")+    return {"success": False, "error": "3MF export failed", "_hint": _HINT_EXPORT_FAILED}+++def handle_export_f3d(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a Fusion 360 archive (.f3d).++    Native Fusion format — preserves all parametric features, sketches,+    timeline history, and component references. Best for archiving or+    sharing with other Fusion 360 users.++    Args:+        outputPath: Full path for the output .f3d file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    export_mgr = design.exportManager+    options = export_mgr.createFusionArchiveExportOptions(output_path)+    result = export_mgr.execute(options)++    if result:+        return _export_success("F3D", output_path,+                               "Fusion 360 native archive — preserves all features and history. ")+    return {"success": False, "error": "F3D export failed", "_hint": _HINT_EXPORT_FAILED}+++def handle_export_usdz(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a USDZ file (.usdz).++    Universal Scene Description (USD) — Pixar's scene-graph format adopted+    by Apple, NVIDIA, and Autodesk. Preserves:+    - Full component hierarchy (board, copper layers, soldermask, packages)+    - PBR materials and colors+    - Named nodes for toggling parts on/off in viewers++    Best format for digital twins, AR (Apple Quick Look), and GLB conversion+    via Blender. The component hierarchy maps directly to toggleable nodes+    in GLB viewers.++    Pipeline: Fusion → USDZ → Blender → GLB (or view USDZ directly on iOS)++    Args:+        outputPath: Full path for the output .usdz file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    # Ensure .usdz extension+    if not output_path.lower().endswith(('.usdz', '.usd', '.usda', '.usdc')):+        output_path = str(Path(output_path).with_suffix('.usdz'))++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    # Pre-delete to avoid overwrite dialogs+    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {+                    "hint": "Close the file in any viewer and retry.",+                    "outputPath": output_path,+                },+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createUSDExportOptions(output_path)+    except AttributeError:+        return {+            "success": False,+            "error": "USDZ export not available in this Fusion 360 version.",+            "_hint": _HINT_FORMAT_UNAVAILABLE,+            "data": {+                "hint": "createUSDExportOptions requires a recent Fusion 360 build. "+                        "Export as STEP instead and convert via Blender.",+                "recoverySteps": [+                    "Export STEP: fusion_export_step (then convert in Blender)",+                    "Export OBJ: fusion_export_fbx (falls back to OBJ)",+                ],+            },+        }++    result = export_mgr.execute(options)++    if result and os.path.exists(output_path):+        size = os.path.getsize(output_path)++        # Get component hierarchy for the response+        components = []+        try:+            root = design.rootComponent+            for i in range(root.occurrences.count):+                occ = root.occurrences.item(i)+                components.append(occ.name)+        except Exception:+            pass++        return {+            "success": True,+            "output": f"Exported USDZ to {output_path}",+            "message": f"USDZ exported ({size // 1024} KB) with full component hierarchy. "+                       f"Contains {len(components)} top-level components that map to "+                       f"toggleable nodes in GLB viewers. "+                       f"Use pull_file to transfer to Docker.",+            "data": {+                "outputPath": output_path,+                "format": "usdz",+                "fileSizeKB": size // 1024,+                "components": components,+                "hint": "USDZ preserves PBR materials and scene hierarchy. "+                        "Each component (Board, copper layers, soldermask, Packages) "+                        "becomes a separate toggleable node in the GLB.",+                "nextSteps": [+                    "Use pull_file to transfer the USDZ to Docker",+                    "Convert to GLB: blender --background --python-expr "+                    "\"import bpy; bpy.ops.wm.read_factory_settings(use_empty=True); "+                    "bpy.ops.wm.usd_import(filepath='input.usdz'); "+                    "bpy.ops.export_scene.gltf(filepath='output.glb')\"",+                    "Or view directly on iOS/macOS — USDZ is Apple's native AR format",+                    "Or import into Unity/Unreal Engine for interactive 3D",+                ],+            },+        }++    return {"success": False, "error": "USDZ export failed", "_hint": _HINT_EXPORT_FAILED}+++def handle_export_fbx(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as an FBX file (.fbx).++    Autodesk FBX format — widely used for 3D interchange with Blender,+    Unity, Unreal Engine, and other DCC tools.++    NOTE: FBX export is not available in current Fusion 360 builds via the API.+    This command will fail clearly — use fusion_export_usdz or fusion_export_step+    instead. No silent fallbacks.++    Args:+        outputPath: Full path for the output .fbx file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    # Pre-delete to avoid overwrite dialogs+    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {+                    "hint": "The file may be open in another application. Close it and retry.",+                    "outputPath": output_path,+                },+            }++    # Try FBX via exportManager API+    export_mgr = design.exportManager+    try:+        options = export_mgr.createFBXExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("FBX", output_path,+                                   "Convert to GLB via Blender: "+                                   "blender --background --python-expr "+                                   "\"import bpy; bpy.ops.import_scene.fbx(filepath='<fbx>'); "+                                   "bpy.ops.export_scene.gltf(filepath='<glb>')\" ")+    except (AttributeError, Exception):+        pass  # createFBXExportOptions doesn't exist in this Fusion build++    # FBX not available — fail clearly, no fallbacks+    return {+        "success": False,+        "error": "FBX export not available in this Fusion 360 build. "+                 "Use fusion_export_usdz or fusion_export_step instead.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "FBX is not supported via the Fusion API in current builds. "+                    "Use fusion_export_usdz (best for Blender/GLB pipeline, preserves "+                    "PBR materials and scene hierarchy) or fusion_export_step "+                    "(highest geometric fidelity).",+            "alternatives": ["fusion_export_usdz", "fusion_export_step", "fusion_export_stl"],+        },+    }+++def handle_export_dxf(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a DXF file (.dxf).++    AutoCAD DXF (Drawing Exchange Format) — universal 2D/3D CAD interchange.+    Widely supported by all CAD tools, laser cutters, CNC routers, and+    PCB fabrication houses.++    Args:+        outputPath: Full path for the output .dxf file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {"outputPath": output_path},+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createDXFExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("DXF", output_path,+                                   "Universal CAD interchange — opens in AutoCAD, LibreCAD, etc. ")+    except (AttributeError, Exception):+        pass++    return {+        "success": False,+        "error": "DXF export failed or not available in this Fusion 360 build.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "Try fusion_export_step for CAD interchange instead.",+            "alternatives": ["fusion_export_step", "fusion_export_dwg"],+        },+    }+++def handle_export_dwg(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a DWG file (.dwg).++    AutoCAD native format — the industry standard for 2D/3D technical+    drawings. Best for sharing with AutoCAD, BricsCAD, DraftSight users.++    Args:+        outputPath: Full path for the output .dwg file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {"outputPath": output_path},+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createDWGExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("DWG", output_path,+                                   "AutoCAD native format — opens in AutoCAD, BricsCAD, etc. ")+    except (AttributeError, Exception):+        pass++    return {+        "success": False,+        "error": "DWG export failed or not available in this Fusion 360 build.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "Try fusion_export_dxf or fusion_export_step instead.",+            "alternatives": ["fusion_export_dxf", "fusion_export_step"],+        },+    }+++def handle_export_iges(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as an IGES file (.igs/.iges).++    Legacy CAD interchange format. Still used by some older CAD systems+    and manufacturing tools. STEP is preferred for modern workflows.++    Args:+        outputPath: Full path for the output .igs file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {"outputPath": output_path},+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createIGESExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("IGES", output_path,+                                   "Legacy CAD interchange — STEP is preferred for modern use. ")+    except (AttributeError, Exception):+        pass++    return {+        "success": False,+        "error": "IGES export failed or not available in this Fusion 360 build.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "Try fusion_export_step instead (modern replacement for IGES).",+            "alternatives": ["fusion_export_step"],+        },+    }+++def handle_export_obj(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as an OBJ file (.obj).++    Wavefront OBJ — simple mesh format widely supported by 3D viewers,+    game engines, and DCC tools. No materials or hierarchy.++    Args:+        outputPath: Full path for the output .obj file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {"outputPath": output_path},+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createOBJExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("OBJ", output_path,+                                   "Simple mesh format — opens in Blender, Unity, etc. ")+    except (AttributeError, Exception):+        pass++    return {+        "success": False,+        "error": "OBJ export failed or not available in this Fusion 360 build.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "Try fusion_export_stl or fusion_export_usdz instead.",+            "alternatives": ["fusion_export_stl", "fusion_export_usdz"],+        },+    }+++def handle_export_sat(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a SAT file (.sat).++    ACIS SAT — solid modeling kernel format. Used by SolidWorks,+    SpaceClaim, and other ACIS-based CAD tools.++    Args:+        outputPath: Full path for the output .sat file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {"outputPath": output_path},+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createSATExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("SAT", output_path,+                                   "ACIS solid modeling format — opens in SolidWorks, SpaceClaim. ")+    except (AttributeError, Exception):+        pass++    return {+        "success": False,+        "error": "SAT export failed or not available in this Fusion 360 build.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "Try fusion_export_step instead.",+            "alternatives": ["fusion_export_step"],+        },+    }+++def handle_export_skp(app: adsk.core.Application, args: dict) -> dict:+    """Export the active design as a SketchUp file (.skp).++    SketchUp format — popular for architectural visualization and+    simple 3D modeling.++    Args:+        outputPath: Full path for the output .skp file (required).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified", "_hint": _HINT_NO_OUTPUT_PATH}++    design = _get_active_design(app)+    if not design:+        return _no_design_error()++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    if os.path.exists(output_path):+        try:+            os.remove(output_path)+        except PermissionError:+            return {+                "success": False,+                "error": f"Cannot overwrite {output_path} — file is locked.",+                "_hint": _HINT_FILE_LOCKED,+                "data": {"outputPath": output_path},+            }++    export_mgr = design.exportManager+    try:+        options = export_mgr.createSKPExportOptions(output_path)+        result = export_mgr.execute(options)+        if result and os.path.exists(output_path):+            return _export_success("SKP", output_path,+                                   "SketchUp format — opens in SketchUp, Trimble viewers. ")+    except (AttributeError, Exception):+        pass++    return {+        "success": False,+        "error": "SketchUp export failed or not available in this Fusion 360 build.",+        "_hint": _HINT_FORMAT_UNAVAILABLE,+        "data": {+            "hint": "Try fusion_export_step or fusion_export_stl instead.",+            "alternatives": ["fusion_export_step", "fusion_export_stl"],+        },+    }
addin/AdomBridge/commands/import_file.pyadded+107
@@ -0,0 +1,107 @@+"""Import file command — open STEP, IGES, SAT, STL, etc. into the active design."""++import adsk.core+import adsk.fusion+from pathlib import Path++SUPPORTED_EXTENSIONS = {+    ".step", ".stp",+    ".iges", ".igs",+    ".sat",+    ".sab",+    ".smb",+    ".smt",+    ".f3d", ".f3z",+    ".stl",+    ".3mf",+    ".obj",+}+++def handle_import_file(app: adsk.core.Application, args: dict) -> dict:+    """Import a file into Fusion 360 using the native import API.++    Args:+        args: Dict with:+            - filePath: Full path to the file to import (required).+    """+    file_path = args.get("filePath", "")+    if not file_path:+        return {"success": False, "error": "No filePath specified"}++    path = Path(file_path)+    if not path.exists():+        return {+            "success": False,+            "error": f"File not found: {file_path}",+            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",+        }++    ext = path.suffix.lower()+    if ext not in SUPPORTED_EXTENSIONS:+        return {+            "success": False,+            "error": f"Unsupported file type: {ext}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",+            "_hint": "Use fusion_import_electronics for .fsch/.fbrd/.flbr electronics files, or verify the extension is one of the 3D formats Fusion supports.",+        }++    import_mgr = app.importManager++    try:+        # Select the right import options based on file type+        if ext in (".step", ".stp"):+            options = import_mgr.createSTEPImportOptions(str(path))+        elif ext in (".iges", ".igs"):+            options = import_mgr.createIGESImportOptions(str(path))+        elif ext in (".sat", ".sab"):+            options = import_mgr.createSATImportOptions(str(path))+        elif ext in (".smb", ".smt"):+            options = import_mgr.createSMTImportOptions(str(path))+        elif ext in (".f3d", ".f3z"):+            options = import_mgr.createFusionArchiveImportOptions(str(path))+        elif ext == ".stl":+            options = import_mgr.createSTLImportOptions(str(path))+        elif ext == ".3mf":+            options = import_mgr.createC3MFImportOptions(str(path))+        elif ext == ".obj":+            options = import_mgr.createOBJImportOptions(str(path))+        else:+            return {+                "success": False,+                "error": f"No import handler for {ext}",+                "_hint": "Use fusion_import_electronics for .fsch/.fbrd/.flbr electronics files, or verify the extension is one of the 3D formats Fusion supports.",+            }++        # Always import to a new document — importToTarget fails on Part Designs+        # because "Part Design documents can only contain one component".+        # importToNewDocument opens the file in its own document tab, which is+        # the expected behavior (like File > Open).+        doc = import_mgr.importToNewDocument(options)+        if not doc:+            return {+                "success": False,+                "error": f"Import returned null for {path.name}",+                "_hint": "Call fusion_screenshot_fusion to check for a blocking dialog (e.g. unit prompt), then fusion_send_key with 'enter' to accept defaults, then retry.",+            }++        # Activate the newly imported document so subsequent commands work on it+        doc.activate()++        # Verify it's now the active document+        active_doc = app.activeDocument+        doc_name = active_doc.name if active_doc else "Unknown"++        return {+            "success": True,+            "output": f"Imported {path.name} into Fusion 360.",+            "data": {+                "document": doc_name,+            },+        }++    except Exception as e:+        return {+            "success": False,+            "error": f"Import failed: {e}",+            "_hint": "Call fusion_screenshot to check for a modal dialog, dismiss with fusion_send_key escape, then retry.",+        }
addin/AdomBridge/commands/manufacturing.pyadded+1185
@@ -0,0 +1,1185 @@+"""Manufacturing export commands — Gerbers, BOM, CPL for PCB fabrication and assembly.++These commands extract manufacturing data from the open PCB board in Fusion 360's+Electronics workspace and write industry-standard output files:+  - Gerbers (RS-274X) + Excellon drill files via EAGLE CAM processor+  - BOM (Bill of Materials) CSV for component purchasing+  - CPL (Component Placement List) CSV for pick-and-place assembly+  - Design rules (DRU) injection for Adom's InstapcbPCB manufacturing capabilities++All three export commands require a .brd board to be open in PCB Editor.++IMPORTANT — EAGLE "Overwrite?" dialog:+  EAGLE's EXPORT commands open a blocking confirmation dialog if the output+  file already exists. This freezes Fusion's UI thread and crashes the add-in.+  Always use _safe_eagle_export() which deletes the file first and runs+  SET CONFIRM YES before any EAGLE export command.+"""++import adsk.core+import os+import csv+import html+import io+import json+import tempfile+import time+import xml.etree.ElementTree as ET+import zipfile+++def _safe_eagle_export(app, eagle_cmd: str, output_file: str) -> dict:+    """Run an EAGLE export command safely, avoiding the 'Overwrite?' dialog.++    EAGLE's EXPORT commands open a blocking confirmation dialog if the output+    file already exists. This helper:+      1. Deletes the target file if it exists+      2. Runs SET CONFIRM YES to suppress any remaining dialogs+      3. Executes the export command++    Returns a dict with 'deletedExisting' flag so the caller can include it+    in response data for AI debugging context.+    """+    result = {"deletedExisting": False}+    if os.path.exists(output_file):+        os.remove(output_file)+        result["deletedExisting"] = True+    app.executeTextCommand("Electron.run SET CONFIRM YES")+    app.executeTextCommand(f"Electron.run {eagle_cmd}")+    return result+++def _get_board_xml(app: adsk.core.Application) -> str:+    """Get board info XML from the current PCB layout via Electron.boardInfo."""+    board_info_path = os.path.join(tempfile.gettempdir(), "adom_mfg_board_info.xml")++    try:+        app.executeTextCommand(f"Electron.boardInfo {board_info_path}")+    except Exception as e:+        err_msg = str(e)+        if "not pcb" in err_msg.lower():+            ws = "unknown"+            try:+                ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+            except Exception:+                pass+            raise RuntimeError(+                f"No board open (current workspace: {ws}). "+                "Open a .brd file first with fusion_open_board."+            )+        raise RuntimeError(f"boardInfo failed: {e}")++    # Read the output file (Fusion writes UTF-16 LE BOM)+    for enc in ("utf-16", "utf-8-sig", "utf-8"):+        try:+            with open(board_info_path, "r", encoding=enc) as f:+                return f.read()+        except (UnicodeDecodeError, UnicodeError):+            continue++    raise RuntimeError("Failed to decode boardInfo XML output.")+++def _parse_components(xml_str: str) -> list:+    """Parse component data from boardInfo XML.++    Returns list of dicts with: name, value, package, library, x, y, rotation, side.+    EAGLE rotation format: R0/R90/R180/R270 = top, MR0/MR90/MR180/MR270 = bottom.+    """+    root = ET.fromstring(xml_str)+    components = []++    for lib_group in root.iter("g"):+        lib = lib_group.get("library")+        pkg = lib_group.get("package")+        if not (lib and pkg):+            continue++        for comp in lib_group.findall("g"):+            name = comp.get("name")+            if not name:+                continue++            rot_str = comp.get("rot", "R0")+            # M prefix = mirrored = bottom side+            side = "Bottom" if rot_str.startswith("M") else "Top"+            # Extract numeric rotation+            rot_num = rot_str.replace("M", "").replace("R", "").replace("S", "")+            try:+                rotation = float(rot_num)+            except ValueError:+                rotation = 0.0++            components.append({+                "name": name,+                "value": comp.get("value", ""),+                "package": pkg,+                "library": lib,+                "x": comp.get("x", "0"),+                "y": comp.get("y", "0"),+                "rotation": rotation,+                "side": side,+            })++    return components+++def _parse_attributes(xml_str: str) -> dict:+    """Parse component attributes from boardInfo XML.++    Returns dict of {component_name: {attr_name: attr_value}}.+    Looks for MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN, LCSC_PN etc.+    """+    root = ET.fromstring(xml_str)+    attrs = {}++    # Attributes are stored as <attribute> elements within component groups+    for elem in root.iter("attribute"):+        name = elem.get("name", "")+        value = elem.get("value", "")+        # The parent chain should lead us to the component+        # For now, collect all attributes+        if name and value:+            # Try to find parent component name+            parent = elem+            comp_name = None+            for _ in range(5):  # Walk up max 5 levels+                parent = None  # ET doesn't have parent refs, use different approach+                break+            if name not in attrs:+                attrs[name] = value++    return attrs+++# ─── BOM Export ───────────────────────────────────────────────────────────++def handle_export_bom(app: adsk.core.Application, args: dict) -> dict:+    """Export Bill of Materials (BOM) from the open PCB board.++    Generates a CSV with component values, quantities, and designators —+    suitable for ordering from Mouser, Digi-Key, or LCSC.++    Two approaches combined:+      1. EAGLE's EXPORT PARTLIST for raw parts data+      2. boardInfo XML parsing for coordinates and grouping++    Args:+        outputPath: File path for the output CSV (default: C:/tmp/adom-bom.csv)+        grouped: If true (default), group identical parts by value+package.+                 If false, list every individual component.+    """+    output_path = args.get("outputPath", "C:/tmp/adom-bom.csv")+    grouped = args.get("grouped", True)++    # Ensure output directory exists+    os.makedirs(os.path.dirname(output_path), exist_ok=True)++    # Also try EXPORT PARTLIST for additional attribute data+    partlist_path = os.path.join(tempfile.gettempdir(), "adom_partlist.txt")+    partlist_data = {}+    try:+        _safe_eagle_export(app, f"EXPORT PARTLIST '{partlist_path}'", partlist_path)+        time.sleep(0.5)+        if os.path.exists(partlist_path):+            for enc in ("utf-16", "utf-8-sig", "utf-8", "latin-1"):+                try:+                    with open(partlist_path, "r", encoding=enc) as f:+                        lines = f.readlines()+                    # Parse tab-separated partlist+                    if len(lines) > 1:+                        headers = lines[0].strip().split("\t")+                        for line in lines[1:]:+                            fields = line.strip().split("\t")+                            if len(fields) >= 2:+                                part_name = fields[0].strip()+                                part_info = {}+                                for i, h in enumerate(headers):+                                    if i < len(fields):+                                        part_info[h.strip()] = fields[i].strip()+                                partlist_data[part_name] = part_info+                    break+                except (UnicodeDecodeError, UnicodeError):+                    continue+    except Exception:+        pass  # EXPORT PARTLIST might fail if not in board view; we still have boardInfo++    # Get components from boardInfo XML+    try:+        xml_str = _get_board_xml(app)+    except RuntimeError as e:+        return {+            "success": False,+            "error": str(e),+            "_hint": "Call fusion_show_2d_board or fusion_open_board {\"filePath\":\"...\"} to enter Board Layout, then retry.",+        }++    components = _parse_components(xml_str)+    if not components:+        return {+            "success": False,+            "error": "No components found in board. Is a populated .brd file open?",+            "data": {+                "hint": "The board XML was parsed but contained no component data. "+                        "This usually means the board has no placed components yet.",+                "recoverySteps": [+                    "Verify the board has components placed (not just an empty board outline)",+                    "Switch to schematic and back: fusion_electron_run with 'BOARD' command",+                    "Try fusion_board_info to get raw board data for debugging",+                ],+            },+        }++    # Merge partlist attributes into component data+    for comp in components:+        pl = partlist_data.get(comp["name"], {})+        if pl.get("Value"):+            comp["value"] = pl["Value"]++    if grouped:+        # Group by value + package (same part = same BOM line)+        groups = {}+        for comp in components:+            key = (comp["value"], comp["package"], comp["library"])+            if key not in groups:+                groups[key] = {+                    "value": comp["value"],+                    "package": comp["package"],+                    "library": comp["library"],+                    "designators": [],+                    "quantity": 0,+                }+            groups[key]["designators"].append(comp["name"])+            groups[key]["quantity"] += 1++        # Sort by designator (natural sort: C1, C2, ..., R1, R2, ...)+        rows = sorted(groups.values(), key=lambda g: g["designators"][0])++        # Write grouped BOM CSV+        with open(output_path, "w", newline="", encoding="utf-8") as f:+            writer = csv.writer(f)+            writer.writerow(["Comment", "Designator", "Footprint", "Quantity", "Library"])+            for row in rows:+                writer.writerow([+                    row["value"],+                    " ".join(sorted(row["designators"])),+                    row["package"],+                    row["quantity"],+                    row["library"],+                ])+    else:+        # Ungrouped — one row per component+        with open(output_path, "w", newline="", encoding="utf-8") as f:+            writer = csv.writer(f)+            writer.writerow(["Designator", "Comment", "Footprint", "Library", "Side"])+            for comp in sorted(components, key=lambda c: c["name"]):+                writer.writerow([+                    comp["name"],+                    comp["value"],+                    comp["package"],+                    comp["library"],+                    comp["side"],+                ])++    unique_count = len(rows) if grouped else len(components)+    return {+        "success": True,+        "output": f"BOM exported: {len(components)} components"+                  + (f" in {unique_count} unique parts" if grouped else "")+                  + f" → {output_path}",+        "message": (+            f"Bill of Materials CSV with {unique_count} unique part"+            f"{'s' if unique_count != 1 else ''} and {len(components)} total placements. "+            "Format: Comment, Designator, Footprint, Quantity, Library — "+            "compatible with JLCPCB, PCBWay, Mouser, and Digi-Key."+        ),+        "data": {+            "outputPath": output_path,+            "componentCount": len(components),+            "uniquePartCount": unique_count,+            "grouped": grouped,+            "nextSteps": [+                "Run fusion_export_cpl to generate the pick-and-place CSV",+                "Run fusion_export_gerbers to generate the Gerber fabrication ZIP",+                "Once you have BOM + CPL + Gerbers, upload all three to your PCB fab service",+            ],+            "hint": (+                "If BOM looks incomplete, check that component values are set in the schematic. "+                "Use fusion_board_info for raw structured component data."+            ),+        },+    }+++# ─── CPL / Pick-and-Place Export ──────────────────────────────────────────++def handle_export_cpl(app: adsk.core.Application, args: dict) -> dict:+    """Export Component Placement List (CPL) for pick-and-place assembly.++    Generates a CSV with component centroid positions, rotation, and side —+    the standard format expected by PCBA manufacturers (JLCPCB, PCBWay, etc.).++    Coordinates are in millimeters relative to the board origin.++    Args:+        outputPath: File path for the output CSV (default: C:/tmp/adom-cpl.csv)+        side: Filter by side — "all" (default), "top", or "bottom"+    """+    output_path = args.get("outputPath", "C:/tmp/adom-cpl.csv")+    side_filter = args.get("side", "all").lower()++    os.makedirs(os.path.dirname(output_path), exist_ok=True)++    try:+        xml_str = _get_board_xml(app)+    except RuntimeError as e:+        return {+            "success": False,+            "error": str(e),+            "_hint": "Call fusion_show_2d_board or fusion_open_board {\"filePath\":\"...\"} to enter Board Layout, then retry.",+        }++    components = _parse_components(xml_str)+    if not components:+        return {+            "success": False,+            "error": "No components found in board. Is a populated .brd file open?",+            "data": {+                "hint": "Board has no component placements. CPL requires components to be placed on the board.",+                "recoverySteps": [+                    "Verify the board has components placed (check with fusion_board_info)",+                    "If schematic has components but board doesn't, the board may need updating",+                ],+            },+        }++    # Filter by side if requested+    if side_filter in ("top", "bottom"):+        target = side_filter.capitalize()+        components = [c for c in components if c["side"] == target]++    if not components:+        return {+            "success": False,+            "error": f"No {side_filter}-side components found.",+        }++    # Write CPL CSV (standard JLCPCB/generic format)+    with open(output_path, "w", newline="", encoding="utf-8") as f:+        writer = csv.writer(f)+        writer.writerow(["Designator", "Mid X", "Mid Y", "Layer", "Rotation"])+        for comp in sorted(components, key=lambda c: c["name"]):+            writer.writerow([+                comp["name"],+                comp["x"],+                comp["y"],+                comp["side"],+                comp["rotation"],+            ])++    top_count = sum(1 for c in components if c["side"] == "Top")+    bottom_count = sum(1 for c in components if c["side"] == "Bottom")++    return {+        "success": True,+        "output": f"CPL exported: {len(components)} placements "+                  f"({top_count} top, {bottom_count} bottom) → {output_path}",+        "message": (+            f"Pick-and-place CSV with {len(components)} component placements "+            f"({top_count} top-side, {bottom_count} bottom-side). "+            "Format: Designator, Mid X, Mid Y, Layer, Rotation — "+            "coordinates in mm, compatible with JLCPCB/PCBWay/Adom PCBA."+        ),+        "data": {+            "outputPath": output_path,+            "totalPlacements": len(components),+            "topCount": top_count,+            "bottomCount": bottom_count,+            "sideFilter": side_filter,+            "nextSteps": [+                "Run fusion_export_bom if you haven't already — you need BOM + CPL together",+                "Run fusion_export_gerbers to generate the Gerber fabrication ZIP",+                "Upload BOM + CPL + Gerbers together to your PCBA service for assembly quoting",+            ],+            "hint": (+                "If placement coordinates look wrong, verify the board origin in EAGLE. "+                "Use side='top' or side='bottom' to export only one side for single-sided assembly."+            ),+        },+    }+++# ─── Resource Path Helper ────────────────────────────────────────────────++def _get_resources_dir() -> str:+    """Get the path to the add-in's bundled resources directory.++    Resources (CAM jobs, DRU files, ULP scripts) are bundled at:+      AdomBridge/resources/  (synced to %APPDATA%/.../AdomBridge/resources/)+    """+    # manufacturing.py is at: AdomBridge/commands/manufacturing.py+    # resources are at:       AdomBridge/resources/+    addin_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))+    return os.path.join(addin_root, "resources")+++# ─── Layer Detection ─────────────────────────────────────────────────────++def _detect_layer_count(app: adsk.core.Application) -> dict:+    """Detect if the open board is 2-layer or 4-layer.++    Uses two methods in priority order:+      1. ULP script — runs detect_layers.ulp to inspect copper layers and traces+      2. CAM comparison — exports with both 2-layer and 4-layer CAM jobs,+         compares ZIP sizes to detect inner copper content++    Returns dict with: layerCount, is4Layer, method, copperLayers, details+    """+    resources = _get_resources_dir()+    result = {"layerCount": 2, "is4Layer": False, "method": "default",+              "copperLayers": [1, 16], "details": ""}++    # Method A: ULP script detection+    ulp_path = os.path.join(resources, "ulp", "detect_layers.ulp").replace("\\", "/")+    if os.path.exists(ulp_path):+        try:+            temp_fd, temp_json = tempfile.mkstemp(suffix=".json")+            os.close(temp_fd)+            temp_json = os.path.abspath(temp_json).replace("\\", "/")++            app.executeTextCommand(f"Electron.run RUN '{ulp_path}' '{temp_json}'")++            if os.path.exists(temp_json):+                with open(temp_json, "r") as f:+                    data = json.loads(f.read())+                os.remove(temp_json)++                copper_count = data.get("copperLayerCount", 0)+                layers_list = data.get("layers", [])+                copper_layers = [+                    l["number"] for l in layers_list+                    if l.get("isCopper") and (l["number"] in (1, 16) or l.get("hasTraces"))+                ]+                internal = [l for l in copper_layers if l not in (1, 16)]++                if internal or copper_count > 2:+                    result.update({+                        "layerCount": max(copper_count, 4),+                        "is4Layer": True,+                        "method": "ulp",+                        "copperLayers": copper_layers,+                        "details": f"ULP found {len(internal)} internal routing layer(s): {internal}",+                    })+                else:+                    result.update({+                        "method": "ulp",+                        "copperLayers": copper_layers,+                        "details": f"ULP found {copper_count} copper layers, no internal routing",+                    })+                return result+        except Exception as e:+            result["details"] = f"ULP detection failed: {e}; "++    # Method B: CAM comparison (export both, compare sizes)+    cam_2 = os.path.join(resources, "cam", "jlcpcb-2layers.cam").replace("\\", "/")+    cam_4 = os.path.join(resources, "cam", "jlcpcb-4layers.cam").replace("\\", "/")++    if os.path.exists(cam_2) and os.path.exists(cam_4):+        try:+            tmp_dir = tempfile.gettempdir().replace("\\", "/")+            zip_2 = f"{tmp_dir}/adom_test_2layer.zip"+            zip_4 = f"{tmp_dir}/adom_test_4layer.zip"++            # Export with 2-layer CAM+            app.executeTextCommand(+                f"Electron.run set confirm yes;manufacturing export '{zip_2}' '{cam_2}'"+            )+            size_2 = os.path.getsize(zip_2) if os.path.exists(zip_2) else 0++            # Export with 4-layer CAM+            app.executeTextCommand(+                f"Electron.run set confirm yes;manufacturing export '{zip_4}' '{cam_4}'"+            )+            size_4 = os.path.getsize(zip_4) if os.path.exists(zip_4) else 0++            # Clean up+            for f in (zip_2, zip_4):+                try:+                    os.remove(f)+                except Exception:+                    pass++            if size_2 > 0 and size_4 > 0:+                ratio = size_4 / size_2+                # Adaptive threshold based on file size+                if size_2 > 100_000:+                    threshold = 1.02+                elif size_2 > 50_000:+                    threshold = 1.03+                else:+                    threshold = 1.05++                if ratio > threshold:+                    result.update({+                        "layerCount": 4, "is4Layer": True, "method": "cam_comparison",+                        "details": f"4-layer ZIP is {ratio:.2f}x larger (threshold {threshold})",+                    })+                else:+                    result.update({+                        "method": "cam_comparison",+                        "details": f"4-layer ZIP ratio {ratio:.2f} (< {threshold}), treating as 2-layer",+                    })+                return result+        except Exception as e:+            result["details"] += f"CAM comparison failed: {e}"++    return result+++def handle_detect_layers(app: adsk.core.Application, args: dict) -> dict:+    """Detect the layer count of the open PCB board.++    Uses ULP script and/or CAM comparison to determine if the board is+    2-layer or 4-layer. Useful before gerber export or design rules application.+    """+    # Verify we're in board layout+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        ws = "unknown"++    if "Board" not in ws and "PCB" not in ws:+        return {+            "success": False,+            "error": f"Not in Board Layout (current: {ws}). Open a .brd file first.",+            "_hint": "Call fusion_show_2d_board or fusion_open_board {\"filePath\":\"...\"} to enter PCB Editor, then retry.",+            "data": {"activeWorkspace": ws},+        }++    detection = _detect_layer_count(app)++    layer_type = f"{detection['layerCount']}-layer"+    return {+        "success": True,+        "output": f"Board detected as {layer_type} ({detection['method']} method). "+                  f"Copper layers: {detection['copperLayers']}",+        "message": (+            f"This is a {layer_type} board. Detection used {detection['method']} method. "+            f"Copper layers found: {detection['copperLayers']}. "+            f"All subsequent manufacturing commands (gerbers, design rules) will auto-select "+            f"the {layer_type} configuration."+        ),+        "data": {+            **detection,+            "activeWorkspace": ws,+            "nextSteps": [+                f"Run fusion_set_design_rules to apply Adom's {layer_type} manufacturing rules",+                f"Run fusion_export_gerbers — auto-selects the {layer_type} CAM job",+                "Run fusion_electron_run with command 'DRC' after applying design rules to check compliance",+            ],+        },+    }+++# ─── Gerber Export ────────────────────────────────────────────────────────++def handle_export_gerbers(app: adsk.core.Application, args: dict) -> dict:+    """Export Gerber manufacturing files from the open PCB board.++    Uses Fusion's CAM processor with bundled JLCPCB-compatible CAM job files+    to produce a ZIP containing industry-standard gerber files:+      - Top/Bottom Copper (.GTL/.GBL)+      - Top/Bottom Silkscreen (.GTO/.GBO)+      - Top/Bottom Solder Mask (.GTS/.GBS)+      - Top/Bottom Paste (.GTP/.GBP)+      - Board Outline (.GKO)+      - Drill file (.XLN)+      - (4-layer boards also get inner copper .G1/.G2)++    Auto-detects 2-layer vs 4-layer boards and selects the right CAM job.++    Args:+        outputDir: Directory for the output ZIP (default: C:/tmp/adom-gerbers/)+        boardName: Prefix for the ZIP filename (default: from active document)+        layers: Force layer count — "2" or "4" (default: auto-detect)+    """+    output_dir = args.get("outputDir", "C:/tmp/adom-gerbers")+    board_name = args.get("boardName", "")+    force_layers = args.get("layers", "auto")++    os.makedirs(output_dir, exist_ok=True)++    # Verify board layout+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        ws = "unknown"++    if "Board" not in ws and "PCB" not in ws:+        return {+            "success": False,+            "error": f"Not in Board Layout (current: {ws}). "+                     "Open a .brd file in PCB Editor first.",+            "_hint": "Call fusion_show_2d_board to switch to PCB Editor, then retry.",+            "data": {+                "activeWorkspace": ws,+                "hint": "Use fusion_show_2d_board to switch to PCB Editor.",+            },+        }++    # Get board name+    if not board_name:+        try:+            doc = app.activeDocument+            if doc:+                board_name = doc.name.replace(".brd", "").replace(".fsch", "").replace(" ", "_")+            else:+                board_name = "board"+        except Exception:+            board_name = "board"++    # Detect layer count+    if force_layers in ("2", "4"):+        layer_count = int(force_layers)+        detection_method = "manual"+    else:+        detection = _detect_layer_count(app)+        layer_count = detection["layerCount"]+        detection_method = detection["method"]++    # Select CAM job file+    resources = _get_resources_dir()+    cam_file = os.path.join(+        resources, "cam", f"jlcpcb-{layer_count}layers.cam"+    ).replace("\\", "/")++    if not os.path.exists(cam_file):+        return {+            "success": False,+            "error": f"CAM job file not found: {cam_file}",+            "data": {+                "hint": "Ensure the resources/cam/ directory contains jlcpcb-2layers.cam "+                        "and jlcpcb-4layers.cam. Run install_addin.py to sync.",+            },+        }++    # Build output ZIP path+    zip_path = os.path.join(output_dir, f"{board_name}_gerbers.zip").replace("\\", "/")++    # Delete existing ZIP to avoid overwrite dialog+    if os.path.exists(zip_path):+        try:+            os.remove(zip_path)+        except Exception:+            pass++    # Execute the CAM export — this is the working command from AdomHelperJoe+    try:+        export_cmd = f"Electron.run set confirm yes;manufacturing export '{zip_path}' '{cam_file}'"+        app.executeTextCommand(export_cmd)+    except Exception as e:+        return {+            "success": False,+            "error": f"Manufacturing export failed: {e}",+            "_hint": "Call fusion_show_2d_board to ensure 2D PCB Editor is active (not 3D view), then retry.",+            "data": {+                "command": f"manufacturing export '{zip_path}' '{cam_file}'",+                "hint": "Ensure you are in the PCB Editor (not schematic or 3D view).",+            },+        }++    # Verify output+    if not os.path.exists(zip_path):+        return {+            "success": False,+            "error": "CAM export command ran but ZIP was not created.",+            "_hint": "Call fusion_show_2d_board to ensure 2D PCB Editor is active, then retry.",+            "data": {+                "zipPath": zip_path,+                "camFile": cam_file,+                "layerCount": layer_count,+                "hint": "Try switching to 2D board view with fusion_show_2d_board, "+                        "then retry.",+            },+        }++    zip_size = os.path.getsize(zip_path)++    # List ZIP contents for the AI+    gerber_files = []+    try:+        with zipfile.ZipFile(zip_path, "r") as zf:+            for info in zf.infolist():+                if not info.is_dir():+                    gerber_files.append({+                        "name": info.filename,+                        "size": info.file_size,+                    })+    except Exception:+        pass++    file_names = [f["name"] for f in gerber_files]+    return {+        "success": True,+        "output": (+            f"Gerbers exported: {len(gerber_files)} files in ZIP "+            f"({zip_size // 1024} KB) → {zip_path}"+        ),+        "message": (+            f"Gerber ZIP created for {layer_count}-layer board '{board_name}' with "+            f"{len(gerber_files)} files ({zip_size // 1024} KB). "+            f"Contains: {', '.join(file_names[:5])}{'...' if len(file_names) > 5 else ''}. "+            "This ZIP is ready for upload to any PCB fabrication service."+        ),+        "data": {+            "zipPath": zip_path,+            "zipSizeKB": zip_size // 1024,+            "layerCount": layer_count,+            "detectionMethod": detection_method,+            "boardName": board_name,+            "files": gerber_files,+            "fileCount": len(gerber_files),+            "nextSteps": [+                "Run fusion_export_bom to generate the Bill of Materials CSV",+                "Run fusion_export_cpl to generate the Component Placement List CSV",+                "Once you have Gerbers + BOM + CPL, upload all three to your PCBA service",+                "Use pull_file to transfer the ZIP back to Docker for ordering",+            ],+            "hint": (+                f"Auto-detected {layer_count}-layer board via {detection_method}. "+                "If wrong, force layer count with layers='2' or layers='4'."+            ),+        },+    }+++# ─── Design Rules (DRU) ──────────────────────────────────────────────────++def _parse_edru_description(edru_path: str) -> str:+    """Parse an .edru file and extract its human-readable description."""+    try:+        tree = ET.parse(edru_path)+        root = tree.getroot()+        desc_elem = root.find(".//description[@language='en']")+        if desc_elem is not None and desc_elem.text:+            return html.unescape(desc_elem.text).strip()+    except Exception:+        pass+    return ""+++def handle_set_design_rules(app: adsk.core.Application, args: dict) -> dict:+    """Set PCB design rules in the open board to match Adom's manufacturing capabilities.++    Loads a bundled .edru design rules file via EAGLE's `drc load` command.+    The .edru files are industry-tested rules from JLCPCB, adapted for Adom's+    InstapcbPCB manufacturing process.++    Auto-detects 2-layer vs 4-layer boards and loads the appropriate rule set.++    Args:+        action: "apply" (default) to load Adom rules, "export" to save current rules,+                "show" to display capabilities without changing anything+        layers: "auto" (default), "2", or "4" — which rule set to load+        outputPath: For "export" — path to save current .dru file+    """+    action = args.get("action", "apply")+    force_layers = args.get("layers", "auto")+    resources = _get_resources_dir()++    if action == "show":+        # Show capabilities from the 2-layer .edru description+        edru_2 = os.path.join(resources, "dru", "Adom-InstaPCB-2-Layer.edru")+        edru_4 = os.path.join(resources, "dru", "Adom-InstaPCB-4-Layer.edru")+        desc_2 = _parse_edru_description(edru_2) if os.path.exists(edru_2) else ""+        desc_4 = _parse_edru_description(edru_4) if os.path.exists(edru_4) else ""++        return {+            "success": True,+            "output": "Adom InstaPCB design rules (from bundled .edru files)",+            "data": {+                "2layer_rules": desc_2 or "2-layer .edru file not found",+                "4layer_rules": desc_4 or "4-layer .edru file not found",+                "nextSteps": [+                    "Use fusion_set_design_rules with action='apply' to load rules into the board",+                    "Layer count is auto-detected, or force with layers='2' or layers='4'",+                ],+            },+        }++    # Verify we're in board layout+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        ws = "unknown"++    if "Board" not in ws and "PCB" not in ws:+        return {+            "success": False,+            "error": f"Not in Board Layout (current: {ws}). Open a .brd file first.",+            "_hint": "Call fusion_show_2d_board or fusion_open_board {\"filePath\":\"...\"} to enter PCB Editor, then retry.",+            "data": {"activeWorkspace": ws},+        }++    if action == "export":+        output_path = args.get("outputPath", "C:/tmp/current-design-rules.dru")+        os.makedirs(os.path.dirname(output_path), exist_ok=True)+        try:+            app.executeTextCommand(f"Electron.run DRC SAVE '{output_path}'")+            time.sleep(0.5)+            if os.path.exists(output_path):+                return {+                    "success": True,+                    "output": f"Current design rules exported to {output_path}",+                    "data": {"outputPath": output_path},+                }+            return {+                "success": False,+                "error": "DRC SAVE did not produce output.",+            }+        except Exception as e:+            return {"success": False, "error": f"DRC SAVE failed: {e}"}++    # action == "apply": Load bundled .edru design rules+    if force_layers in ("2", "4"):+        layer_count = int(force_layers)+        detection_method = "manual"+    else:+        detection = _detect_layer_count(app)+        layer_count = detection["layerCount"]+        detection_method = detection["method"]++    # Resolve the .edru file+    edru_filename = f"Adom-InstaPCB-{layer_count}-Layer.edru"+    edru_path = os.path.join(resources, "dru", edru_filename)++    if not os.path.exists(edru_path):+        return {+            "success": False,+            "error": f"Design rules file not found: {edru_path}",+            "data": {+                "hint": "Ensure resources/dru/ contains the .edru files. "+                        "Run install_addin.py to sync.",+            },+        }++    edru_path_fwd = edru_path.replace("\\", "/")++    # Load via EAGLE's drc load command (lowercase — this is the correct syntax)+    try:+        app.executeTextCommand(f"Electron.run drc load '{edru_path_fwd}'")+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to load design rules: {e}",+            "data": {"edruPath": edru_path_fwd},+        }++    # Parse description for the success message+    description = _parse_edru_description(edru_path)++    return {+        "success": True,+        "output": (+            f"Adom {layer_count}-layer design rules loaded from {edru_filename}. "+            f"Detection method: {detection_method}."+        ),+        "message": (+            f"Adom InstaPCB {layer_count}-Layer design rules are now active. "+            f"Min trace: 0.08mm (3mil), min spacing: 0.08mm (3mil), min drill: 0.2mm (8mil). "+            f"Rule violations will now show as DRC markers in the board editor."+        ),+        "data": {+            "edruFile": edru_filename,+            "edruPath": edru_path_fwd,+            "layerCount": layer_count,+            "detectionMethod": detection_method,+            "description": description,+            "activeWorkspace": ws,+            "nextSteps": [+                "Run fusion_electron_run with command 'DRC' to check for rule violations",+                "Run fusion_export_gerbers to export Gerber files with the correct CAM job",+                "Run fusion_export_bom and fusion_export_cpl to complete the manufacturing package",+            ],+            "hint": (+                "DRC violations appear as markers in the board. Use fusion_board_info to get "+                "structured DRC data, or fusion_export_board_image with preset 'fabrication' "+                "to visually inspect the result."+            ),+        },+    }+++# ─── Board Image Export (PNG / PDF / layer views) ────────────────────────++# Layer presets for common manufacturing views+LAYER_PRESETS = {+    "all": {+        "description": "All layers visible",+        "command": "DISPLAY ALL",+    },+    "top_copper": {+        "description": "Top copper + pads + vias",+        "layers": [1, 17, 18],+    },+    "bottom_copper": {+        "description": "Bottom copper + pads + vias",+        "layers": [16, 17, 18],+    },+    "top_silkscreen": {+        "description": "Top silkscreen + names",+        "layers": [21, 25],+    },+    "bottom_silkscreen": {+        "description": "Bottom silkscreen + names",+        "layers": [22, 26],+    },+    "top_soldermask": {+        "description": "Top solder mask openings",+        "layers": [29],+    },+    "bottom_soldermask": {+        "description": "Bottom solder mask openings",+        "layers": [30],+    },+    "top_paste": {+        "description": "Top paste/stencil openings",+        "layers": [31],+    },+    "bottom_paste": {+        "description": "Bottom paste/stencil openings",+        "layers": [32],+    },+    "board_outline": {+        "description": "Board outline (dimension layer)",+        "layers": [20],+    },+    "drill": {+        "description": "Drill holes + vias",+        "layers": [44, 45, 17, 18],+    },+    "assembly_top": {+        "description": "Top assembly view — copper + silk + outline + names",+        "layers": [1, 17, 18, 20, 21, 25, 51],+    },+    "assembly_bottom": {+        "description": "Bottom assembly view — copper + silk + outline + names",+        "layers": [16, 17, 18, 20, 22, 26, 52],+    },+    "fabrication": {+        "description": "Full fabrication view — all copper + mask + silk + outline",+        "layers": [1, 16, 17, 18, 19, 20, 21, 22, 25, 26, 29, 30, 51],+    },+}+++def handle_export_board_image(app: adsk.core.Application, args: dict) -> dict:+    """Export a PNG image of the open PCB board with configurable layer visibility.++    Uses EAGLE's EXPORT IMAGE command to render the board at a specified DPI.+    Layer presets make it easy to export common manufacturing views (silkscreen,+    copper, outline, etc.) without manually toggling layers.++    After export, restores the previous layer visibility.++    Args:+        outputPath: File path for the output image (default: C:/tmp/adom-board.png)+        dpi: Resolution in dots per inch (default: 300, max: 600)+        preset: Layer preset name — one of:+            "all", "top_copper", "bottom_copper", "top_silkscreen",+            "bottom_silkscreen", "top_soldermask", "bottom_soldermask",+            "top_paste", "bottom_paste", "board_outline", "drill",+            "assembly_top", "assembly_bottom", "fabrication"+        layers: Custom layer numbers (list of ints) — overrides preset if provided+        monochrome: If true, export in black & white (default: false)+        mirror: If true, mirror the image horizontally (default: false)+        listPresets: If true, return available presets instead of exporting+    """+    # List presets mode+    if args.get("listPresets", False):+        preset_list = {k: v["description"] for k, v in LAYER_PRESETS.items()}+        return {+            "success": True,+            "output": f"{len(preset_list)} layer presets available",+            "data": {"presets": preset_list},+        }++    output_path = args.get("outputPath", "C:/tmp/adom-board.png")+    dpi = min(int(args.get("dpi", 300)), 600)+    preset = args.get("preset", "all")+    custom_layers = args.get("layers", None)+    monochrome = args.get("monochrome", False)+    mirror_img = args.get("mirror", False)++    os.makedirs(os.path.dirname(output_path), exist_ok=True)++    # Verify we're in an electronics editor+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        ws = "unknown"++    # Works in Board Layout and Schematic Editor+    is_electronics = False+    try:+        app.executeTextCommand("Electron.run DISPLAY")+        is_electronics = True+    except Exception:+        pass++    if not is_electronics:+        return {+            "success": False,+            "error": f"Not in Electronics editor (current: {ws}). "+                     "Open a .brd or .sch file first.",+            "_hint": "Call fusion_open_board {\"filePath\":\"...\"} or fusion_show_2d_board to enter the PCB editor, then retry.",+            "data": {+                "activeWorkspace": ws,+                "hint": "Use fusion_open_board or fusion_show_2d_board to enter the PCB editor.",+                "recoverySteps": [+                    "Open a .brd file with fusion_open_board",+                    "Or switch to 2D board view with fusion_show_2d_board",+                    "Then retry this command",+                ],+            },+        }++    # Save current layer state (we'll restore it after export)+    # There's no direct way to query current DISPLAY state, so we'll+    # just restore ALL after export.++    # Silkscreen registration: when exporting a silkscreen layer, fit the view to+    # the BOARD OUTLINE (layer 20) rather than the silk content. The silk content's+    # bbox is smaller than (and offset within) the board, so a content-fit PNG can't+    # be UV-mapped to the board's XY bbox by a downstream overlay (e.g. OCCT+    # apply_silkscreen). We show the outline ONLY to key WINDOW FIT to the board+    # bbox, then hide it before EXPORT so it's not baked into the silk PNG.+    # (Pixel-exact bbox crop + bottom-side mirror are done container-side, where an+    # image lib is available — Fusion's add-in Python has none.)+    silk_export_display = None  # set for silk presets: the silk-only DISPLAY used for EXPORT++    # Set layer visibility+    if custom_layers:+        layer_nums = " ".join(str(l) for l in custom_layers)+        display_cmd = f"DISPLAY NONE; DISPLAY {layer_nums}"+    elif preset in LAYER_PRESETS:+        p = LAYER_PRESETS[preset]+        if "command" in p:+            display_cmd = p["command"]+        else:+            layer_nums = " ".join(str(l) for l in p["layers"])+            if preset in ("top_silkscreen", "bottom_silkscreen"):+                # Fit with the outline visible; export with silk only.+                display_cmd = f"DISPLAY NONE; DISPLAY {layer_nums} 20"+                silk_export_display = f"DISPLAY NONE; DISPLAY {layer_nums}"+            else:+                display_cmd = f"DISPLAY NONE; DISPLAY {layer_nums}"+    else:+        return {+            "success": False,+            "error": f"Unknown preset '{preset}'. Use listPresets to see available options.",+            "_hint": "Call fusion_export_board_image with {\"listPresets\": true} to see valid preset names.",+            "data": {"availablePresets": list(LAYER_PRESETS.keys())},+        }++    try:+        # Set layers — split semicolon-separated commands into individual calls+        for cmd in display_cmd.split(";"):+            cmd = cmd.strip()+            if cmd:+                app.executeTextCommand(f"Electron.run {cmd}")+                time.sleep(0.2)+        time.sleep(0.3)++        # Zoom to fit. For silk presets the board outline (layer 20) is visible+        # here, so WINDOW FIT keys the view to the board's XY bbox.+        app.executeTextCommand("Electron.run WINDOW FIT")+        time.sleep(0.3)++        # Silk: hide the outline now (keep the same window extent) so the EXPORT+        # captures only silkscreen, board-bbox-keyed.+        if silk_export_display:+            for cmd in silk_export_display.split(";"):+                cmd = cmd.strip()+                if cmd:+                    app.executeTextCommand(f"Electron.run {cmd}")+                    time.sleep(0.15)+            time.sleep(0.2)++        # Delete existing file to avoid overwrite dialog+        output_path_escaped = output_path.replace("\\", "/")+        if os.path.exists(output_path_escaped):+            os.remove(output_path_escaped)++        # Export image+        export_cmd = f"EXPORT IMAGE '{output_path_escaped}' {dpi}"+        if monochrome:+            export_cmd = f"EXPORT IMAGE '{output_path_escaped}' MONOCHROME {dpi}"++        app.executeTextCommand(f"Electron.run SET CONFIRM YES")+        app.executeTextCommand(f"Electron.run {export_cmd}")+        time.sleep(1.0)++    except Exception as e:+        # Restore layers before returning error+        try:+            app.executeTextCommand("Electron.run DISPLAY ALL")+        except Exception:+            pass+        return {+            "success": False,+            "error": f"Export failed: {e}",+            "data": {+                "activeWorkspace": ws,+                "preset": preset,+                "hint": "EXPORT IMAGE may fail if no board/schematic content is visible. "+                        "Try WINDOW FIT first, or use a different layer preset.",+            },+        }++    # Restore all layers visible+    try:+        app.executeTextCommand("Electron.run DISPLAY ALL")+    except Exception:+        pass++    # Verify output file exists+    if not os.path.exists(output_path):+        return {+            "success": False,+            "error": f"EXPORT IMAGE did not create file at {output_path}. "+                     "The command may not be supported in this context.",+            "_hint": "Call fusion_show_2d_board first to ensure 2D PCB Editor is active, then retry.",+        }++    file_size = os.path.getsize(output_path)+    preset_desc = LAYER_PRESETS.get(preset, {}).get("description", preset) if not custom_layers else f"custom layers: {custom_layers}"++    return {+        "success": True,+        "output": f"Board image exported ({preset_desc}) at {dpi} DPI → {output_path}",+        "message": (+            f"PNG image of {preset_desc} at {dpi} DPI ({file_size // 1024} KB). "+            "Use pull_file to transfer to Docker for inspection or documentation."+        ),+        "data": {+            "outputPath": output_path,+            "dpi": dpi,+            "preset": preset if not custom_layers else "custom",+            "presetDescription": preset_desc,+            "fileSize": file_size,+            "fileSizeKB": file_size // 1024,+            "monochrome": monochrome,+            "activeWorkspace": ws,+            "nextSteps": [+                "Use pull_file to transfer the image to Docker for review",+                "Export more views: 'assembly_top', 'assembly_bottom', 'top_copper', 'board_outline'",+                "For manufacturing: also export gerbers, BOM, and CPL",+            ],+            "hint": (+                "Common presets: 'assembly_top' (component placement review), "+                "'top_copper' (routing check), 'fabrication' (full fab view), "+                "'board_outline' (dimensions check)."+            ),+        },+    }
addin/AdomBridge/commands/modeling.pyadded+68
@@ -0,0 +1,68 @@+"""In-app parametric modeling for the Adom Bridge.++`run_modeling_script` executes a Python snippet against the live Fusion 360+modeling API (adsk.fusion) inside the user's open Fusion session. This is the+FREE, immediate path to programmatic CAD (sketches, extrudes, components,+parameters) — as opposed to the APS Fusion Automation API, which runs headless+in the cloud but BILLS per processing hour. Use this to build parametric parts+(e.g. a JST connector) from a script.++The snippet runs ON THE MAIN THREAD (the add-in marshals every command through a+CustomEvent), which is required by the Fusion modeling API. The snippet's globals+include `adsk`, `app`, `ui`, and a `design` (the active Fusion Design, created if+none is open). Anything the snippet `print()`s is captured; set a top-level+`result` variable to return structured data.+"""++import io+import contextlib+import traceback++import adsk.core+import adsk.fusion+++def handle_run_modeling_script(app: adsk.core.Application, args: dict) -> dict:+    script = args.get("script") or args.get("code") or ""+    if not script:+        return {"success": False, "error": "No script provided.",+                "_hint": "Pass {\"script\": \"<python using adsk.fusion>\"}. Globals available: "+                         "adsk, app, ui, design (active Design, created if none open)."}++    ui = app.userInterface+    # Ensure there's a design to model into — create a fresh Fusion design if the+    # active product isn't one (e.g. the user is on the data panel / an empty tab).+    design = adsk.fusion.Design.cast(app.activeProduct)+    if not design:+        app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)+        design = adsk.fusion.Design.cast(app.activeProduct)+    if not design:+        return {"success": False, "error": "Could not obtain a Fusion Design to model into.",+                "_hint": "Open or create a Fusion design document, then retry. A read-only/expired "+                         "Fusion can still MODEL in memory; only saving to the cloud is blocked."}++    ns = {+        "adsk": adsk,+        "app": app,+        "ui": ui,+        "design": design,+        "__name__": "__adom_modeling__",+    }+    buf = io.StringIO()+    try:+        with contextlib.redirect_stdout(buf):+            exec(script, ns)+    except Exception as e:+        return {"success": False, "error": str(e),+                "data": {"traceback": traceback.format_exc(), "stdout": buf.getvalue()},+                "_hint": "Modeling-script error (a code/geometry problem, NOT the Fusion license). "+                         "Read-only Fusion still creates geometry in memory; only cloud-save is blocked."}++    res = ns.get("result")+    # Keep the return JSON-safe.+    if not isinstance(res, (str, int, float, bool, dict, list, type(None))):+        res = str(res)+    out = buf.getvalue().strip()+    return {"success": True,+            "output": out or "Modeling script ran successfully.",+            "data": {"result": res, "documentName": app.activeDocument.name if app.activeDocument else None}}
addin/AdomBridge/commands/open_electronics_file.pyadded+543
@@ -0,0 +1,543 @@+"""Open electronics files in Fusion 360's Electronics workspace.++Each file type gets its own command for clarity:+  fusion_open_schematic  — .sch files → Schematic Editor+  fusion_open_board      — .brd files → Board Layout editor+  fusion_show_3d_board   — switches Board Layout → 3D PCB view++All use Document.newDesignFromLocal which reliably opens these file types+and switches to the correct Electronics sub-workspace automatically.++fusion_open_lbr (in open_lbr.py) handles .lbr library files.++Fusion 360 Electronics projects are cloud-based (not .epr files like standalone EAGLE).+The project is the Fusion document itself. Open a schematic or board to work with a design.+"""++import adsk.core+import os+import time+++def _open_electronics(app: adsk.core.Application, file_path: str, file_type: str, expected_ext: str) -> dict:+    """Common logic for opening an electronics file in Fusion."""+    if not file_path:+        return {+            "success": False,+            "error": f"No filePath specified. Provide a {expected_ext} file path.",+            "data": {+                "hint": f"Usage: fusion_open_{file_type.replace(' ', '_')} {{\"filePath\": \"C:/path/to/file{expected_ext}\"}}"+            },+        }++    file_path = file_path.replace("\\", "/")++    if not os.path.exists(file_path):+        return {+            "success": False,+            "error": f"File not found: {file_path}",+            "_hint": "The path may exist on Docker but not on the Windows host. Verify with desktop_list_directory on the host, or use push_file to copy from Docker first.",+            "data": {"filePath": file_path, "hint": "Check that the file path exists on the Windows desktop machine, not on Docker."},+        }++    ext = os.path.splitext(file_path)[1].lower()+    if ext != expected_ext:+        return {+            "success": False,+            "error": f"Expected a {expected_ext} file, got {ext}. Use the correct command for this file type.",+            "data": {+                "hint": {+                    ".sch": "Use fusion_open_schematic for .sch files",+                    ".brd": "Use fusion_open_board for .brd files",+                    ".lbr": "Use fusion_open_lbr for .lbr files",+                    ".step": "Use fusion_import_step for .step files",+                    ".stp": "Use fusion_import_step for .stp files",+                }.get(ext, f"This command only handles {expected_ext} files"),+            },+        }++    try:+        r = app.executeTextCommand(f"Document.newDesignFromLocal {file_path}")+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to open {file_type}: {e}",+            "_hint": "Call fusion_screenshot to check for a modal dialog blocking Fusion, then fusion_send_key escape to dismiss it, then retry.",+            "data": {+                "filePath": file_path,+                "hint": "Fusion 360 may not support this file. Ensure Fusion is fully loaded and responsive.",+            },+        }++    time.sleep(1.0)++    # Get workspace info to confirm we landed in the right editor+    ws_info = {}+    try:+        active_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+        ws_info["activeWorkspace"] = active_ws.strip() if active_ws else "unknown"+    except Exception:+        pass+    try:+        doc = app.activeDocument+        if doc:+            ws_info["activeDocument"] = doc.name+    except Exception:+        pass++    # Build helpful next-steps based on file type+    next_steps = {+        "schematic": [+            "Use fusion_electron_run to send EAGLE commands (e.g. WINDOW FIT, DISPLAY ALL)",+            "Use fusion_take_screenshot to capture the schematic view",+            "Use fusion_close_document to close without saving when done",+        ],+        "board layout": [+            "Use fusion_show_3d_board to switch to 3D PCB visualization",+            "Use fusion_electron_run to send EAGLE commands (e.g. WINDOW FIT, RATSNEST)",+            "Use fusion_take_screenshot to capture the board view",+            "Use fusion_close_document to close without saving when done",+        ],+    }++    return {+        "success": True,+        "output": f"Opened {file_type}: {os.path.basename(file_path)}",+        "message": f"Opened {file_type}: {os.path.basename(file_path)}. "+                   f"IMPORTANT: Fusion may show a blocking dialog that is NOT visible to the API. "+                   f"You MUST screenshot the Fusion window now to verify no dialog is blocking.",+        "data": {+            "filePath": file_path,+            "fileType": file_type,+            "nextSteps": next_steps.get(file_type, []),+            **ws_info,+        },+        "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",+            ],+        },+    }+++def handle_open_schematic(app: adsk.core.Application, args: dict) -> dict:+    """Open a schematic (.sch) file in Fusion 360's Schematic Editor.++    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.++    The schematic opens in the Electronics workspace where you can use+    EAGLE commands via fusion_electron_run (e.g. WINDOW FIT, DISPLAY ALL).++    Args:+        args: Dict with:+            - filePath: Path to the .sch file on the Windows desktop (required).+    """+    return _open_electronics(app, args.get("filePath", ""), "schematic", ".sch")+++def handle_open_board(app: adsk.core.Application, args: dict) -> dict:+    """Open a board layout (.brd) file in Fusion 360's Board Layout editor.++    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.++    Opens the 2D board layout where you can see traces, pads, and components.+    Use fusion_show_3d_board to switch to the 3D PCB visualization.+    Use fusion_electron_run {"command": "BOARD"} to return to 2D.++    Args:+        args: Dict with:+            - filePath: Path to the .brd file on the Windows desktop (required).+    """+    return _open_electronics(app, args.get("filePath", ""), "board layout", ".brd")+++def handle_show_3d_board(app: adsk.core.Application, args: dict) -> dict:+    """Switch to the 3D PCB board view in Fusion 360.++    Must have a board (.brd) open in the Board Layout editor first.+    The board MUST have an outline on layer 20 (Dimension) — without it,+    Fusion cannot generate the 3D model.++    Uses the Fusion command Switch3dPcbDocCmd to switch workspaces.++    To return to 2D board layout, use:+        fusion_execute_text_command {"command": "Commands.Start Electron::Insert2DPCB"}+    """+    # Check current workspace+    ws = "unknown"+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    # Already in 3D PCB?+    if ws == "3D PCB":+        return {+            "success": True,+            "output": "Already in 3D PCB view.",+            "data": {+                "activeWorkspace": ws,+                "nextSteps": [+                    "Use fusion_take_screenshot to capture the 3D board view",+                    "Use fusion_show_2d_board to return to 2D layout",+                    "Use fusion_close_document to close without saving when done",+                ],+            },+        }++    # Must be in PCB Editor (board layout) to switch to 3D+    is_pcb_editor = False+    try:+        app.executeTextCommand("Electron.run DISPLAY")+        is_pcb_editor = True+    except Exception:+        pass++    if not is_pcb_editor:+        return {+            "success": False,+            "error": f"Not in Electronics editor (current workspace: {ws}). "+                     "Open a .brd board file first with fusion_open_board.",+            "_hint": "Call fusion_open_board {\"filePath\":\"...\"} to load a board, then retry fusion_show_3d_board.",+            "data": {+                "activeWorkspace": ws,+                "hint": "You need to open a .brd file first: fusion_open_board {\"filePath\": \"C:/path/to/board.brd\"}",+            },+        }++    # Switch to 3D PCB using Fusion's workspace switch command+    try:+        result = app.executeTextCommand("Commands.Start Switch3dPcbDocCmd")+    except Exception as e:+        err_msg = str(e)+        if "outline" in err_msg.lower() or "missing" in err_msg.lower():+            return {+                "success": False,+                "error": f"3D PCB failed: {e}. The board may be missing an outline on layer 20 (Dimension).",+                "_hint": "Call fusion_electron_run with a WIRE command on layer 20 to add a board outline, then retry fusion_show_3d_board.",+                "data": {+                    "hint": "Add a board outline on layer 20 (Dimension) using WIRE command, then try again.",+                },+            }+        return {+            "success": False,+            "error": f"Switch to 3D PCB failed: {e}",+            "data": {"activeWorkspace": ws},+        }++    time.sleep(2.0)++    # Zoom to fit in 3D view+    try:+        app.executeTextCommand("Commands.Start FitCommand")+    except Exception:+        pass  # FitCommand may report missing 3D models as an "error" but still works++    # Verify we're now in 3D PCB+    new_ws = "unknown"+    try:+        new_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    if new_ws != "3D PCB":+        return {+            "success": False,+            "error": f"Switch to 3D PCB may have failed (workspace: {new_ws}). "+                     "Ensure the board has an outline on layer 20.",+            "_hint": "Call fusion_screenshot to check state, then verify the board has a layer-20 outline before retrying.",+            "data": {"activeWorkspace": new_ws},+        }++    return {+        "success": True,+        "output": "Switched to 3D PCB board view. Board outline, copper, solder mask, and component placeholders are shown in 3D.",+        "message": "Switched to 3D PCB view. IMPORTANT: Fusion may show a 'What to design?' or "+                   "'PCB out of date' dialog. You MUST screenshot the Fusion window to verify.",+        "data": {+            "activeWorkspace": new_ws,+            "nextSteps": [+                "FIRST: Screenshot Fusion window to check for blocking dialogs — dismiss with fusion_send_key escape",+                "Use fusion_take_screenshot to capture the 3D board view",+                "Use fusion_show_2d_board to return to 2D layout",+                "Use fusion_close_document to close without saving when done",+            ],+        },+        "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\"}",+                "Screenshot again to confirm the dialog is gone",+            ],+        },+    }+++def handle_import_electronics(app: adsk.core.Application, args: dict) -> dict:+    """Import a Fusion-native electronics file (.fsch/.fbrd/.flbr) as a new local project.++    These are Fusion 360's own electronics formats (not legacy EAGLE .sch/.brd).+    Uses Document.newDesignFromLocal to create a new document from the local file.++    After importing, use fusion_save_to_cloud to persist to the Fusion cloud.++    Args:+        args: Dict with:+            - filePath: Path to the .fsch, .fbrd, or .flbr file on Windows (required).+    """+    file_path = args.get("filePath", "")+    if not file_path:+        return {+            "success": False,+            "error": "No filePath specified. Provide a .fsch, .fbrd, or .flbr file path.",+            "data": {+                "hint": "Usage: fusion_import_electronics {\"filePath\": \"C:/path/to/file.fsch\"}",+                "supportedExtensions": [".fsch", ".fbrd", ".flbr"],+            },+        }++    file_path = file_path.replace("\\", "/")++    if not os.path.exists(file_path):+        return {+            "success": False,+            "error": f"File not found: {file_path}",+            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy it from Docker first, or verify the host-side path.",+            "data": {"filePath": file_path, "hint": "Check that the file path exists on the Windows desktop machine."},+        }++    ext = os.path.splitext(file_path)[1].lower()+    valid_extensions = {".fsch", ".fbrd", ".flbr"}+    if ext not in valid_extensions:+        ext_hints = {+            ".sch": "Use fusion_open_schematic for legacy EAGLE .sch files",+            ".brd": "Use fusion_open_board for legacy EAGLE .brd files",+            ".lbr": "Use fusion_open_lbr for legacy EAGLE .lbr files",+            ".step": "Use fusion_import_step for .step files",+            ".stp": "Use fusion_import_step for .stp files",+        }+        return {+            "success": False,+            "error": f"Unsupported extension '{ext}'. This command handles Fusion-native electronics formats: .fsch, .fbrd, .flbr",+            "_hint": ext_hints.get(ext, f"Supported: {', '.join(sorted(valid_extensions))}"),+            "data": {+                "hint": ext_hints.get(ext, f"Supported: {', '.join(sorted(valid_extensions))}"),+                "supportedExtensions": sorted(valid_extensions),+            },+        }++    file_type_map = {+        ".fsch": "Fusion schematic",+        ".fbrd": "Fusion board",+        ".flbr": "Fusion library",+    }+    file_type = file_type_map[ext]++    try:+        r = app.executeTextCommand(f"Document.newDesignFromLocal {file_path}")+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to import {file_type}: {e}",+            "data": {+                "filePath": file_path,+                "hint": "Fusion 360 may not support this file version. Ensure Fusion is fully loaded and responsive.",+            },+        }++    time.sleep(1.5)++    # Get workspace info to confirm we landed in the right editor+    ws_info = {}+    try:+        active_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace")+        ws_info["activeWorkspace"] = active_ws.strip() if active_ws else "unknown"+    except Exception:+        pass+    try:+        doc = app.activeDocument+        if doc:+            ws_info["activeDocument"] = doc.name+    except Exception:+        pass++    next_steps = []+    if ext == ".fsch":+        next_steps = [+            "Use fusion_electron_run to send EAGLE commands (e.g. WINDOW FIT)",+            "Use fusion_save_to_cloud to persist this document to the Fusion cloud",+            "Use fusion_close_document to close without saving when done",+        ]+    elif ext == ".fbrd":+        next_steps = [+            "Use fusion_show_3d_board to switch to 3D PCB visualization",+            "Use fusion_electron_run to send EAGLE commands (e.g. WINDOW FIT, RATSNEST)",+            "Use fusion_save_to_cloud to persist this document to the Fusion cloud",+            "Use fusion_close_document to close without saving when done",+        ]+    elif ext == ".flbr":+        next_steps = [+            "Use fusion_export_lbr to verify library contents (.scr text format)",+            "Use fusion_save_to_cloud to persist this library to the Fusion cloud",+            "Use fusion_close_document to close without saving when done",+        ]++    return {+        "success": True,+        "output": f"Imported {file_type}: {os.path.basename(file_path)}",+        "message": f"Imported {file_type}: {os.path.basename(file_path)}. "+                   f"This is a NEW local document — use fusion_save_to_cloud to persist it. "+                   f"IMPORTANT: Fusion may show a blocking dialog. Screenshot to verify.",+        "data": {+            "filePath": file_path,+            "fileType": file_type,+            "extension": ext,+            "nextSteps": next_steps,+            **ws_info,+        },+        "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\"}",+                "Screenshot again to confirm the dialog is gone",+            ],+        },+    }+++def handle_show_2d_board(app: adsk.core.Application, args: dict) -> dict:+    """Switch back to the 2D board layout from 3D PCB view.++    Uses the Fusion command Electron::Insert2DPCB to return to PCB Editor.+    """+    ws = "unknown"+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    if ws == "PCB Editor":+        return {+            "success": True,+            "output": "Already in 2D board layout.",+            "data": {"activeWorkspace": ws},+        }++    try:+        app.executeTextCommand("Commands.Start SwitchPcbDocCmd")+    except Exception as e:+        return {+            "success": False,+            "error": f"Switch to 2D board failed: {e}",+            "_hint": "Call fusion_open_board {\"filePath\":\"...\"} to ensure a board is loaded, then retry.",+            "data": {"activeWorkspace": ws},+        }++    time.sleep(1.0)++    new_ws = "unknown"+    try:+        new_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    return {+        "success": True,+        "output": "Switched to 2D board layout. EAGLE commands via fusion_electron_run will work.",+        "data": {+            "activeWorkspace": new_ws,+            "nextSteps": [+                "Use fusion_electron_run to send EAGLE commands",+                "Use fusion_show_3d_board to switch back to 3D view",+            ],+        },+    }+++def handle_show_schematic(app: adsk.core.Application, args: dict) -> dict:+    """Switch the OPEN electronics design to its SCHEMATIC view.++    The schematic / 2D board / 3D board are VIEWS of ONE electronics design - open the+    PROJECT first (fusion_open_cloud_file / fusion_open_by_urn), then switch views with+    fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board. Uses Fusion's+    SwitchSchDocCmd (the symmetric partner of SwitchPcbDocCmd / Switch3dPcbDocCmd).+    Drive the schematic for demos with fusion_electron_zoom / _pan / _select.+    """+    ws = "unknown"+    try:+        ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass++    # Already viewing the schematic?+    cur_pt = ""+    try:+        cur_pt = app.activeProduct.productType+    except Exception:+        pass+    if cur_pt == "SchematicProductType":+        return {+            "success": True,+            "output": "Already in the schematic view.",+            "data": {"activeWorkspace": ws, "productType": cur_pt},+        }++    try:+        app.executeTextCommand("Commands.Start SwitchSchDocCmd")+    except Exception as e:+        return {+            "success": False,+            "error": f"Switch to schematic failed: {e}",+            "_hint": "Open the electronics DESIGN first (fusion_open_cloud_file / fusion_open_by_urn), "+                     "then retry. The schematic is a VIEW of the design, not a standalone file.",+            "data": {"activeWorkspace": ws},+        }++    time.sleep(1.2)+    try:+        app.executeTextCommand("Electron.run WINDOW FIT")+    except Exception:+        pass++    new_ws = "unknown"+    new_pt = ""+    try:+        new_ws = app.executeTextCommand("DebugCommands.ActiveWorkspace").strip()+    except Exception:+        pass+    try:+        new_pt = app.activeProduct.productType+    except Exception:+        pass++    return {+        "success": True,+        "output": "Switched to the schematic view. Drive it with fusion_electron_zoom / _pan / _select "+                  "or fusion_electron_run WINDOW commands.",+        "message": "Switched to the schematic. Fusion may show a blocking dialog that is NOT visible to "+                   "the API - screenshot the Fusion window to verify.",+        "data": {+            "activeWorkspace": new_ws,+            "productType": new_pt,+            "nextSteps": [+                "fusion_electron_zoom / fusion_electron_pan to move the view for a recording",+                "fusion_show_2d_board / fusion_show_3d_board to switch to the board / 3D",+                "fusion_take_screenshot to capture the schematic",+            ],+        },+        "postOpenCheck": {+            "required": True,+            "steps": [+                "Run desktop_screenshot_window on the Fusion HWND",+                "If a dialog is visible, dismiss it with fusion_send_key {\"key\": \"escape\"}",+            ],+        },+    }
addin/AdomBridge/commands/open_lbr.pyadded+183
@@ -0,0 +1,183 @@+"""Open an EAGLE .lbr library file in Fusion 360's Electronics workspace.++Uses the Document.newDesignFromLocal text command to open the file, which+switches Fusion to the Electronics Library editor automatically.+This avoids the fragile workspace-switching dance.++Note: Document.Open fails with error 3 for .lbr files, but+Document.newDesignFromLocal works reliably.++Flow:+  1. Write .lbr XML file to disk (caller provides path or XML content)+  2. Document.newDesignFromLocal <path> opens it in Electronics Library editor+  3. Optionally navigate to a specific symbol with Electron.run EDIT <name>.sym+  4. Optionally export via EXPORT SCRIPT for round-trip verification+"""++import adsk.core+import os+import tempfile+import time+++def handle_open_lbr(app: adsk.core.Application, args: dict) -> dict:+    """Open an EAGLE .lbr file in the Electronics Library editor.++    Args:+        args: Dict with:+            - filePath: Path to the .lbr file on disk (required).+            - symbolName: Optional symbol name to navigate to after opening.+            - verify: If True, exports the library back via EXPORT SCRIPT.+    """+    file_path = args.get("filePath", "")+    symbol_name = args.get("symbolName", "")+    verify = args.get("verify", False)++    if not file_path:+        return {"success": False, "error": "No filePath specified"}++    # Normalize path for Fusion (forward slashes)+    file_path = file_path.replace("\\", "/")++    if not os.path.exists(file_path):+        return {+            "success": False,+            "error": f"File not found: {file_path}",+            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker, or verify the host-side path.",+        }++    if not file_path.lower().endswith(".lbr"):+        return {+            "success": False,+            "error": f"Not a .lbr file: {file_path}",+            "_hint": "Use fusion_open_schematic for .sch files, fusion_open_board for .brd files, or fusion_import_electronics for .fsch/.fbrd/.flbr files.",+        }++    results = []++    # Step 1: Open the .lbr file via Document.newDesignFromLocal+    # (Document.Open fails with error 3 for .lbr files)+    try:+        r = app.executeTextCommand(f"Document.newDesignFromLocal {file_path}")+        results.append(f"Document.newDesignFromLocal: {r if r else 'ok'}")+    except Exception as e:+        return {+            "success": False,+            "error": f"Document.newDesignFromLocal failed: {e}",+            "_hint": "Call fusion_screenshot to check for a modal dialog blocking Fusion, dismiss with fusion_send_key escape, then retry.",+            "data": {"filePath": file_path},+        }++    # Step 2: If a symbol name was given, navigate to it+    if symbol_name:+        # Give Fusion a moment to finish opening the library+        time.sleep(1)++        sym_ref = symbol_name if symbol_name.endswith(".sym") else f"{symbol_name}.sym"+        try:+            app.executeTextCommand(f"Electron.run EDIT {sym_ref}")+            results.append(f"EDIT {sym_ref}: ok")+        except Exception as e:+            results.append(f"EDIT {sym_ref} failed: {e}")++        # Zoom to fit+        try:+            app.executeTextCommand("Electron.run WINDOW FIT")+            results.append("WINDOW FIT: ok")+        except Exception:+            pass++    # Step 3: Verification via EXPORT SCRIPT+    verification = None+    if verify:+        verification = _export_and_verify(app, symbol_name)+        results.append(f"Verification: exported={verification.get('exported', False)}")++    response = {+        "success": True,+        "output": f"Opened library: {os.path.basename(file_path)}",+        "data": {"results": results, "filePath": file_path},+    }+    if symbol_name:+        response["data"]["symbolName"] = symbol_name+    if verification:+        response["data"]["verification"] = verification+    return response+++def handle_export_lbr(app: adsk.core.Application, args: dict) -> dict:+    """Export the current Electronics library via EXPORT SCRIPT.++    This exports the library contents to an EAGLE .scr script file,+    which is a text representation of the entire library. Useful for+    verifying that a library was loaded correctly (round-trip test).++    Args:+        args: Dict with:+            - outputPath: Path to write the .scr export (optional,+                         defaults to a temp file).+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        import uuid+        output_path = os.path.join(tempfile.gettempdir(), f"_adom_export_{uuid.uuid4().hex[:8]}.scr")++    output_path = output_path.replace("\\", "/")++    try:+        app.executeTextCommand(f"Electron.run EXPORT SCRIPT '{output_path}'")+    except Exception as e:+        return {+            "success": False,+            "error": f"EXPORT SCRIPT failed: {e}. "+                     "Ensure you are in an Electronics editor (library, schematic, or board).",+            "_hint": "Call fusion_open_lbr {\"filePath\":\"...\"} to enter the Electronics Library editor, then retry.",+        }++    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)+    # Read a preview of the exported content+    with open(output_path, "r", errors="replace") as f:+        preview = f.read(2000)++    return {+        "success": True,+        "output": f"Exported library to {output_path} ({size} bytes)",+        "data": {+            "outputPath": output_path,+            "fileSize": size,+            "preview": preview,+        },+    }+++def _export_and_verify(app: adsk.core.Application, symbol_name: str = "") -> dict:+    """Export library and check if the symbol exists in the output."""+    import uuid+    verify_path = os.path.join(tempfile.gettempdir(), f"_adom_verify_{uuid.uuid4().hex[:8]}.scr")+    try:+        app.executeTextCommand(f"Electron.run EXPORT SCRIPT '{verify_path}'")+        if os.path.exists(verify_path):+            with open(verify_path, "r", errors="replace") as f:+                content = f.read()+            result = {+                "exported": True,+                "fileSize": os.path.getsize(verify_path),+                "preview": content[:1000],+            }+            if symbol_name:+                # EAGLE uppercases symbol names in EXPORT SCRIPT output+                has_symbol = (+                    f"Edit '{symbol_name.upper()}.sym'" in content+                    or f"Edit '{symbol_name}.sym'" in content+                )+                result["hasSymbol"] = has_symbol+            return result+        return {"exported": False, "error": "Export file not created"}+    except Exception as e:+        return {"exported": False, "error": str(e)}
addin/AdomBridge/commands/parameters.pyadded+134
@@ -0,0 +1,134 @@+"""Parameter commands — get and set design parameters."""++import adsk.core+import adsk.fusion+++def handle_get_parameters(app: adsk.core.Application, args: dict) -> dict:+    """Get all parameters (user + model) from the active design.++    Args:+        args: Optional dict with:+            - type: "user", "model", or "all" (default: "all").+    """+    product = app.activeProduct+    if not product or product.productType != "DesignProductType":+        return {+            "success": False,+            "error": "No active design open in Fusion 360",+            "_hint": "Call fusion_open_cloud_file to open a Design document, then retry.",+        }++    design = adsk.fusion.Design.cast(product)+    param_type = args.get("type", "all").lower()++    result = []++    if param_type in ("user", "all"):+        for i in range(design.userParameters.count):+            p = design.userParameters.item(i)+            result.append({+                "name": p.name,+                "type": "user",+                "expression": p.expression,+                "value": p.value,+                "unit": p.unit,+                "comment": p.comment,+            })++    if param_type in ("model", "all"):+        for i in range(design.allParameters.count):+            p = design.allParameters.item(i)+            # Skip user params if we already included them+            if param_type == "all" and _is_user_param(design, p.name):+                continue+            result.append({+                "name": p.name,+                "type": "model",+                "expression": p.expression,+                "value": p.value,+                "unit": p.unit,+            })++    return {+        "success": True,+        "data": result,+        "output": f"Found {len(result)} parameter(s).",+    }+++def handle_set_parameter(app: adsk.core.Application, args: dict) -> dict:+    """Set a parameter's expression by name.++    Args:+        args: Dict with:+            - name: Parameter name (required).+            - expression: New expression string (required), e.g. "10 mm".+    """+    name = args.get("name", "")+    expression = args.get("expression", "")+    if not name:+        return {"success": False, "error": "No parameter name specified"}+    if not expression:+        return {"success": False, "error": "No expression specified"}++    product = app.activeProduct+    if not product or product.productType != "DesignProductType":+        return {+            "success": False,+            "error": "No active design open in Fusion 360",+            "_hint": "Call fusion_open_cloud_file to open a Design document, then retry.",+        }++    design = adsk.fusion.Design.cast(product)++    # Try user parameters first, then all parameters+    param = None+    for i in range(design.userParameters.count):+        p = design.userParameters.item(i)+        if p.name == name:+            param = p+            break++    if not param:+        for i in range(design.allParameters.count):+            p = design.allParameters.item(i)+            if p.name == name:+                param = p+                break++    if not param:+        return {+            "success": False,+            "error": f"Parameter not found: {name}",+            "_hint": "Call fusion_get_parameters to list all valid parameter names, then retry with a correct name.",+        }++    old_expr = param.expression+    try:+        param.expression = expression+    except Exception as e:+        return {+            "success": False,+            "error": f"Failed to set parameter: {e}",+            "_hint": "Verify the expression format (e.g., \"10 mm\", \"5 deg\") and that units match the parameter's unit type.",+        }++    return {+        "success": True,+        "output": f"Parameter '{name}' changed: {old_expr} -> {expression}",+        "data": {+            "name": param.name,+            "expression": param.expression,+            "value": param.value,+            "unit": param.unit,+        },+    }+++def _is_user_param(design: adsk.fusion.Design, name: str) -> bool:+    """Check if a parameter name exists in the user parameters."""+    for i in range(design.userParameters.count):+        if design.userParameters.item(i).name == name:+            return True+    return False
addin/AdomBridge/commands/screenshot.pyadded+136
@@ -0,0 +1,136 @@+"""Screenshot command — capture the Fusion 360 viewport."""++import adsk.core+import os+import time+from pathlib import Path+++def _build_view_orientations():+    """Build orientation map at runtime to avoid crashing if enum values differ."""+    orientations = {"home": None}+    try:+        vo = adsk.core.ViewOrientations+        mapping = {+            "front": "FrontViewOrientation",+            "back": "BackViewOrientation",+            "top": "TopViewOrientation",+            "bottom": "BottomViewOrientation",+            "left": "LeftViewOrientation",+            "right": "RightViewOrientation",+            "iso_top_front_right": "IsoTopFrontRightViewOrientation",+            "iso_top_front_left": "IsoTopFrontLeftViewOrientation",+            "iso_top_back_right": "IsoTopBackRightViewOrientation",+            "iso_top_back_left": "IsoTopBackLeftViewOrientation",+            "iso_bottom_front_right": "IsoBottomFrontRightViewOrientation",+            "iso_bottom_front_left": "IsoBottomFrontLeftViewOrientation",+            "iso_bottom_back_right": "IsoBottomBackRightViewOrientation",+            "iso_bottom_back_left": "IsoBottomBackLeftViewOrientation",+        }+        for name, attr in mapping.items():+            if hasattr(vo, attr):+                orientations[name] = getattr(vo, attr)+    except Exception:+        pass+    return orientations+++def handle_take_screenshot(app: adsk.core.Application, args: dict) -> dict:+    """Capture the active viewport to a PNG file.++    Uses Fusion's viewport render API (same as Capture Image from the menu)+    for high-quality renders at any resolution, without opening a dialog.++    Args:+        args: Dict with:+            - outputPath: Full path for the output .png file (required).+            - width: Image width in pixels (default: 1920).+            - height: Image height in pixels (default: 1080).+            - orientation: Camera orientation preset (optional).+              One of: "home", "front", "back", "top", "bottom", "left", "right",+              "iso_top_front_right", "iso_top_front_left", etc.+            - fitToView: If true, zoom to fit all geometry (default: true).+            - listOrientations: If true, return available orientations.+    """+    VIEW_ORIENTATIONS = _build_view_orientations()++    # List orientations mode+    if args.get("listOrientations", False):+        return {+            "success": True,+            "output": f"{len(VIEW_ORIENTATIONS)} view orientations available",+            "data": {"orientations": list(VIEW_ORIENTATIONS.keys())},+        }++    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified"}++    width = int(args.get("width", 1920))+    height = int(args.get("height", 1080))+    orientation = args.get("orientation", "").lower().strip()+    fit_to_view = args.get("fitToView", True)++    viewport = app.activeViewport+    if not viewport:+        return {+            "success": False,+            "error": "No active viewport",+            "_hint": "Call fusion_open_cloud_file or fusion_open_board to open a document first — there is nothing to screenshot.",+        }++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    # Set camera orientation if specified+    orientation_applied = None+    if orientation:+        if orientation not in VIEW_ORIENTATIONS:+            return {+                "success": False,+                "error": f"Unknown orientation '{orientation}'. "+                         f"Available: {', '.join(VIEW_ORIENTATIONS.keys())}",+            }++        camera = viewport.camera+        view_enum = VIEW_ORIENTATIONS[orientation]++        if view_enum is not None:+            camera.viewOrientation = view_enum+        camera.isFitView = True if fit_to_view else False+        viewport.camera = camera++        # Let Fusion render the new view+        adsk.doEvents()+        time.sleep(0.3)+        adsk.doEvents()++        orientation_applied = orientation+    elif fit_to_view:+        camera = viewport.camera+        camera.isFitView = True+        viewport.camera = camera+        adsk.doEvents()++    result = viewport.saveAsImageFile(output_path, width, height)++    if result:+        size = os.path.getsize(output_path) if os.path.exists(output_path) else 0+        response = {+            "success": True,+            "output": f"Screenshot saved to {output_path} ({width}x{height})",+            "data": {+                "outputPath": output_path,+                "width": width,+                "height": height,+                "fileSizeKB": size // 1024,+            },+        }+        if orientation_applied:+            response["data"]["orientation"] = orientation_applied+        return response++    return {+        "success": False,+        "error": "Screenshot capture failed",+        "_hint": "Try desktop_screenshot_window with the Fusion HWND as a fallback (captures the full window including UI chrome).",+    }
addin/AdomBridge/commands/silkscreen_capture.pyadded+308
@@ -0,0 +1,308 @@+"""Silkscreen capture — render isolated top/bottom silkscreen layer PNGs.++Hides all 3D bodies except the target silkscreen layer, aims the camera+at the PCB bounding box, captures a 2048x2048 transparent-background PNG,+then restores the original visibility and camera state.++This is a low-level primitive. Orchestration (which documents to open,+which workspace to activate, where to save) belongs in the skill layer.+"""++import adsk.core+import adsk.fusion+import os+import time+from pathlib import Path+++def _pump_events(iterations=5):+    """Flush Fusion's event queue so visibility/camera changes render."""+    try:+        for _ in range(iterations):+            adsk.doEvents()+    except Exception:+        pass+++def _compute_pcb_bbox(design):+    """Union bounding boxes of all bodies to frame the board."""+    combined = None++    def merge(bbox):+        nonlocal combined+        if not bbox:+            return+        if combined is None:+            combined = adsk.core.BoundingBox3D.create(+                bbox.minPoint.copy(), bbox.maxPoint.copy()+            )+        else:+            combined.expand(bbox.minPoint)+            combined.expand(bbox.maxPoint)++    for ci in range(design.allComponents.count):+        comp = design.allComponents.item(ci)+        for bi in range(comp.bRepBodies.count):+            try:+                merge(comp.bRepBodies.item(bi).boundingBox)+            except Exception:+                pass+        for mi in range(comp.meshBodies.count):+            try:+                merge(comp.meshBodies.item(mi).boundingBox)+            except Exception:+                pass+    return combined+++def _bbox_center_and_extent(bbox):+    if bbox is None:+        return adsk.core.Point3D.create(0, 0, 0), 20.0, 20.0+    mn, mx = bbox.minPoint, bbox.maxPoint+    cx = (mn.x + mx.x) / 2+    cy = (mn.y + mx.y) / 2+    cz = (mn.z + mx.z) / 2+    xy_ext = max(abs(mx.x - mn.x), abs(mx.y - mn.y), 1.0)+    z_ext = max(abs(mx.z - mn.z), 1.0)+    return adsk.core.Point3D.create(cx, cy, cz), xy_ext, z_ext+++def _is_silk_match(name, target):+    """True if body/mesh/canvas name matches the target silkscreen layer."""+    n = name.lower()+    return "silk" in n and target in n+++def _store_visibility(design):+    """Snapshot visibility of all bodies, meshes, and canvases."""+    state = {"root_b": {}, "root_m": {}, "comps": {}}+    root = design.rootComponent+    for i in range(root.bRepBodies.count):+        state["root_b"][i] = root.bRepBodies.item(i).isVisible+    for i in range(root.meshBodies.count):+        state["root_m"][i] = root.meshBodies.item(i).isVisible+    for ci in range(design.allComponents.count):+        comp = design.allComponents.item(ci)+        ck = ci+        state["comps"][ck] = {"b": {}, "m": {}, "cv": {}}+        for bi in range(comp.bRepBodies.count):+            state["comps"][ck]["b"][bi] = comp.bRepBodies.item(bi).isVisible+        for mi in range(comp.meshBodies.count):+            state["comps"][ck]["m"][mi] = comp.meshBodies.item(mi).isVisible+        for cvi in range(comp.canvases.count):+            try:+                c = comp.canvases.item(cvi)+                state["comps"][ck]["cv"][cvi] = getattr(c, "isLightBulbOn", True)+            except Exception:+                pass+    return state+++def _restore_visibility(design, state):+    """Restore a previous visibility snapshot."""+    if not state:+        return+    root = design.rootComponent+    for i, vis in state.get("root_b", {}).items():+        try:+            root.bRepBodies.item(i).isVisible = vis+        except Exception:+            pass+    for i, vis in state.get("root_m", {}).items():+        try:+            root.meshBodies.item(i).isVisible = vis+        except Exception:+            pass+    for ci in range(design.allComponents.count):+        cs = state.get("comps", {}).get(ci, {})+        comp = design.allComponents.item(ci)+        for bi, vis in cs.get("b", {}).items():+            try:+                comp.bRepBodies.item(bi).isVisible = vis+            except Exception:+                pass+        for mi, vis in cs.get("m", {}).items():+            try:+                comp.meshBodies.item(mi).isVisible = vis+            except Exception:+                pass+        for cvi, vis in cs.get("cv", {}).items():+            try:+                c = comp.canvases.item(cvi)+                if hasattr(c, "isLightBulbOn"):+                    c.isLightBulbOn = vis+            except Exception:+                pass+++def _hide_all_except_silk(design, target):+    """Hide everything except the target silkscreen layer ('top'/'bottom').++    Silkscreen in Fusion 3D PCB is stored as Canvases (not bodies).+    Canvases are overlaid on the Board body, so the Board body must stay+    visible as a backdrop — otherwise the canvas renders over nothing and+    the viewport capture comes out blank.+    """+    root = design.rootComponent++    for i in range(root.bRepBodies.count):+        b = root.bRepBodies.item(i)+        b.isVisible = False+    for i in range(root.meshBodies.count):+        m = root.meshBodies.item(i)+        m.isVisible = False++    for ci in range(design.allComponents.count):+        comp = design.allComponents.item(ci)+        comp_name = comp.name.lower()++        for bi in range(comp.bRepBodies.count):+            b = comp.bRepBodies.item(bi)+            # Keep the Board body visible — canvases need it as a backdrop+            if comp_name == "board" and b.name.lower() == "board":+                b.isVisible = True+            else:+                b.isVisible = False++        for mi in range(comp.meshBodies.count):+            m = comp.meshBodies.item(mi)+            m.isVisible = False++        for cvi in range(comp.canvases.count):+            c = comp.canvases.item(cvi)+            try:+                if hasattr(c, "isLightBulbOn"):+                    c.isLightBulbOn = _is_silk_match(c.name, target)+            except Exception:+                pass+++def _capture_png(viewport, output_path, width=2048, height=2048):+    """Save viewport as PNG, preferring transparent background."""+    try:+        opts = adsk.core.SaveImageFileOptions.create(output_path)+        opts.width = width+        opts.height = height+        opts.isBackgroundTransparent = True+        opts.isAntiAliased = True+        if viewport.saveAsImageFileWithOptions(opts):+            return True+    except Exception:+        pass+    try:+        return viewport.saveAsImageFile(output_path, width, height)+    except Exception:+        return False+++# ── Public handler ──────────────────────────────────────────────────────++def handle_take_silkscreen_screenshot(app: adsk.core.Application, args: dict) -> dict:+    """Capture an isolated silkscreen layer as a transparent-background PNG.++    Requires the active document to be a 3D Design (not PCB Editor).+    Call fusion_show_3d_board first if you're in the 2D board view.++    Args:+        outputPath (str): Full path for the output .png file (required).+        layer (str): "top" or "bottom" (required).+        width (int): Image width in pixels (default: 2048).+        height (int): Image height in pixels (default: 2048).++    Returns success + outputPath + fileSizeKB.+    """+    output_path = args.get("outputPath", "")+    if not output_path:+        return {+            "success": False,+            "error": "outputPath is required",+            "_hint": 'Provide {"outputPath": "C:/tmp/silk_top.png", "layer": "top"}',+        }++    layer = args.get("layer", "").lower().strip()+    if layer not in ("top", "bottom"):+        return {+            "success": False,+            "error": f"layer must be 'top' or 'bottom', got '{layer}'",+            "_hint": '{"outputPath": "...", "layer": "top"}',+        }++    width = int(args.get("width", 2048))+    height = int(args.get("height", 2048))++    # Need an active Design product (3D view)+    product = app.activeProduct+    if not product or product.productType != "DesignProductType":+        return {+            "success": False,+            "error": "No active Design document. Silkscreen capture requires the 3D PCB view.",+            "_hint": "Call fusion_show_3d_board to switch to the 3D view, then retry.",+        }+    design = adsk.fusion.Design.cast(product)++    viewport = app.activeViewport+    if not viewport:+        return {"success": False, "error": "No active viewport"}++    Path(output_path).parent.mkdir(parents=True, exist_ok=True)++    # Save state+    camera = viewport.camera+    orig_eye = camera.eye.copy()+    orig_target = camera.target.copy()+    orig_up = camera.upVector.copy()+    bbox = _compute_pcb_bbox(design)+    vis_state = _store_visibility(design)++    try:+        center, xy_ext, z_ext = _bbox_center_and_extent(bbox)++        # Hide everything except the target silkscreen layer+        _hide_all_except_silk(design, layer)++        # Aim camera straight down (top) or straight up (bottom)+        if layer == "top":+            eye_z = center.z + z_ext + xy_ext * 2.0+        else:+            eye_z = center.z - z_ext - xy_ext * 2.0++        camera.eye = adsk.core.Point3D.create(center.x, center.y, eye_z)+        camera.target = adsk.core.Point3D.create(center.x, center.y, center.z)+        camera.upVector = adsk.core.Vector3D.create(0, 1, 0)+        camera.viewExtents = xy_ext * 1.2+        camera.isSmoothTransition = False+        viewport.camera = camera+        viewport.refresh()++        # Let GPU finish rendering the new visibility state+        _pump_events()+        time.sleep(1.0)++        ok = _capture_png(viewport, output_path, width, height)+        if not ok:+            return {+                "success": False,+                "error": "Viewport capture failed",+                "_hint": "Try desktop_screenshot_window as a fallback.",+            }++        size = os.path.getsize(output_path) if os.path.exists(output_path) else 0+        return {+            "success": True,+            "output": f"Silkscreen {layer} captured to {output_path}",+            "data": {+                "outputPath": output_path.replace("\\", "/"),+                "layer": layer,+                "width": width,+                "height": height,+                "fileSizeKB": round(size / 1024, 1),+            },+        }+    finally:+        # Always restore+        camera.eye = orig_eye+        camera.target = orig_target+        camera.upVector = orig_up+        viewport.camera = camera+        viewport.refresh()+        _restore_visibility(design, vis_state)
addin/AdomBridge/http_server.pyadded+427
@@ -0,0 +1,427 @@+"""Background HTTP server for the Adom Bridge add-in.++Runs on a daemon thread inside Fusion 360's process.+Incoming POST /command requests fire a CustomEvent to marshal execution+to the main Fusion thread, then wait for the result.++Threading model:+    - HTTP server is ThreadingHTTPServer: multiple HTTP threads can serve+      requests concurrently (for /health, status, etc.).+    - Fusion's main thread is a HARD SINGLETON. All Fusion API work is+      marshaled via CustomEvent and executed serially there.+    - A module-level _main_thread_lock serializes CustomEvent fires so that+      only one request has work pending on the main thread at any time.+      New HTTP threads block on the lock until the in-flight request+      completes. This prevents request stacking (which wastes main-thread+      cycles on exports whose HTTP waiter already gave up).+    - A staleness check in set_result() drops results whose HTTP waiter has+      already timed out. The CustomEventHandler also checks staleness+      BEFORE dispatching, so it can skip heavy work entirely.+"""++import json+import threading+import time+import uuid+from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler++import adsk.core++from .commands.cloud_documents import get_walk_progress++PORT = 8774++_server = None+_server_thread = None+_custom_event_id = None+_app = None++# Thread-safe result passing: request_id -> (Event, result_dict)+_result_events = {}+_result_data = {}+_lock = threading.Lock()++# Main thread singleton lock — only ONE request may have a CustomEvent+# in flight to Fusion's main thread at any time. HTTP threads block+# here to prevent request stacking.+_main_thread_lock = threading.Lock()++# Main thread liveness tracking+_last_main_thread_ts = 0.0  # time.time() of last successful set_result()+_pending_request_ids = set()  # request_ids currently waiting for main thread++# Busy state tracking — which command currently holds the main thread lock.+# Read by the /status endpoint (non-blocking) so ANY bridge can check if+# the add-in is busy, even cross-process. This is the single source of truth.+_busy_state_lock = threading.Lock()+_busy_state = None  # None or {"command": str, "started_at": float, "request_id": str}+++def _set_busy(command: str, request_id: str):+    with _busy_state_lock:+        global _busy_state+        _busy_state = {"command": command, "started_at": time.time(), "request_id": request_id}+++def _clear_busy():+    with _busy_state_lock:+        global _busy_state+        _busy_state = None+++def _get_busy() -> dict | None:+    with _busy_state_lock:+        return dict(_busy_state) if _busy_state else None+++# ── Stale-lock watchdog (v1.0.2) ──────────────────────────────────────+#+# Failure mode being defended against:+#   Fusion's native cloud SDK can wedge on a slow / failed cloud API call+#   while holding the Python GIL. While wedged, Python code in the addin+#   does NOT run — including the per-command timeout in _wait_for_result.+#   The HTTP thread eventually times out (after `timeout` seconds) and+#   returns to the client, but the `finally` block can't run because the+#   thread is blocked at the C++/Fusion boundary. Net result:+#     - _main_thread_lock stays held forever+#     - _busy_state stays populated forever+#     - Every new POST /command request blocks on _main_thread_lock.acquire()+#       for its `timeout` seconds, then returns main_thread_busy+#     - From the user's perspective, the addin is dead until Fusion restarts+#+# Defense: a daemon thread checks _busy_state every 5s. If a busy state+# has been active longer than the per-command timeout + WATCHDOG_GRACE,+# we forcibly release _main_thread_lock so new requests can proceed.+# We can't unwedge the previously-stuck command (it's blocked in C++),+# but at least subsequent calls work without restarting Fusion.++WATCHDOG_GRACE = 30  # seconds beyond per-command timeout before force-release+_watchdog_stop = threading.Event()+_watchdog_thread = None+++def _watchdog_loop():+    """Force-release _main_thread_lock if a command has been busy too long."""+    while not _watchdog_stop.is_set():+        try:+            busy = _get_busy()+            if busy:+                cmd = busy.get("command", "")+                started = busy.get("started_at", 0.0)+                elapsed = time.time() - started+                # Get the timeout that applied to this command+                cmd_timeout = PER_COMMAND_TIMEOUT.get(cmd, COMMAND_TIMEOUT)+                if elapsed > cmd_timeout + WATCHDOG_GRACE:+                    try:+                        adsk.core.Application.log(+                            f"AdomBridge: WATCHDOG force-releasing _main_thread_lock — "+                            f"'{cmd}' held for {elapsed:.1f}s "+                            f"(> {cmd_timeout}+{WATCHDOG_GRACE}s grace). "+                            f"Fusion main thread likely wedged on native cloud call. "+                            f"Subsequent requests can now proceed; the wedged "+                            f"command will still leak its C++/Python state until "+                            f"Fusion restarts."+                        )+                    except Exception:+                        pass+                    # Clear the busy marker FIRST so /status no longer+                    # reports the stale command.+                    _clear_busy()+                    # Force-release. If the lock owner thread eventually+                    # unwedges and tries to release(), it'll get a+                    # RuntimeError which we accept as the cost of keeping+                    # subsequent requests flowing.+                    try:+                        _main_thread_lock.release()+                    except RuntimeError:+                        # Already released (race with the owner thread+                        # somehow making progress). Fine.+                        pass+                    # Also drop any pending request_ids so set_result()+                    # from a late-arriving wedged command can't leak.+                    with _lock:+                        _pending_request_ids.clear()+        except Exception:+            pass  # watchdog must never die+        _watchdog_stop.wait(5)  # check every 5s+++# Default per-command timeout. Heavy 3D exports (STEP/IGES/SAT/STL on+# panelized boards with 100+ placements) can take 120s+. Set generously.+COMMAND_TIMEOUT = 300  # seconds++# Per-command timeout overrides. Fast commands keep short timeouts so a+# hung main thread is surfaced quickly.+PER_COMMAND_TIMEOUT = {+    "get_app_state": 10,+    "document_info": 15,+    "take_screenshot": 30,+    "activate_document": 30,+    "close_document": 30,+    "close_all_documents": 60,+    # Exports can be slow on heavy designs+    "export_step": 300,+    "export_iges": 300,+    "export_sat": 300,+    "export_stl": 300,+    "export_3mf": 300,+    "export_usdz": 300,+    "export_obj": 300,+    "export_f3d": 300,+    "export_fbx": 300,+    "export_dxf": 120,+    "export_dwg": 120,+    "export_skp": 300,+    # Manufacturing exports (run EAGLE ULPs, can be slow)+    "export_gerbers": 180,+    "export_bom": 60,+    "export_cpl": 60,+    "export_board_image": 60,+    # Cloud tree walking / search — BFS over hundreds of folders+    "walk_cloud_tree": 600,+    "search_cloud_files": 180,+}+++def is_request_alive(request_id: str) -> bool:+    """Check whether an HTTP thread is still waiting for this request.++    Called from the main thread (CustomEventHandler) BEFORE doing heavy+    work, so we can skip exports whose HTTP waiter has already bailed.+    """+    with _lock:+        return request_id in _result_events+++def set_result(request_id: str, result: dict):+    """Called from the main thread (CustomEventHandler) to deliver a result."""+    with _lock:+        _last_main_thread_ts_local = time.time()+        # Update module-level timestamp+        global _last_main_thread_ts+        _last_main_thread_ts = _last_main_thread_ts_local++        _pending_request_ids.discard(request_id)++        event = _result_events.get(request_id)+        if event:+            # Normal path: HTTP thread is still waiting+            _result_data[request_id] = result+            event.set()+        # else: HTTP thread already timed out — discard result (don't leak)+++def _wait_for_result(request_id: str, timeout: float) -> dict:+    """Wait for the main thread to deliver a result for this request."""+    event = threading.Event()+    with _lock:+        _result_events[request_id] = event++    got_result = event.wait(timeout=timeout)++    with _lock:+        _result_events.pop(request_id, None)+        result = _result_data.pop(request_id, None)+        if not got_result:+            # Timed out — remove from pending so set_result() won't leak+            _pending_request_ids.discard(request_id)++    if not got_result or result is None:+        return {"success": False, "error": f"Command timed out after {timeout}s"}++    return result+++class AdomBridgeHandler(BaseHTTPRequestHandler):+    """HTTP handler for the add-in server."""++    def do_GET(self):+        if self.path == "/health":+            with _lock:+                pending = len(_pending_request_ids)+                last_ts = _last_main_thread_ts+            main_lock_held = _main_thread_lock.locked()++            # Determine main thread status+            now = time.time()+            if last_ts == 0.0 and pending == 0:+                main_status = "idle"  # No commands processed yet, none pending+            elif main_lock_held and pending > 0 and (last_ts == 0.0 or (now - last_ts) > COMMAND_TIMEOUT):+                main_status = "blocked"  # Commands pending but main thread not responding+            elif main_lock_held:+                main_status = "busy"  # A command is running on main thread+            else:+                main_status = "responsive"++            self._respond(200, {+                "status": "ok",+                "addin": "AdomBridge",+                "port": PORT,+                "main_thread": main_status,+                "main_thread_locked": main_lock_held,+                "pending_commands": pending,+                "main_thread_last_seen": round(last_ts, 1) if last_ts > 0 else None,+                "walk_progress": get_walk_progress(),+            })+        elif self.path == "/status":+            # Non-blocking busy probe — does NOT acquire _main_thread_lock.+            # Any number of callers (from any bridge/container) can hit this+            # concurrently. Returns which command holds the lock, how long+            # it's been running, and walk progress if applicable.+            busy = _get_busy()+            walk = get_walk_progress()+            if busy:+                elapsed = round(time.time() - busy["started_at"], 1)+                resp = {+                    "busy": True,+                    "busyCommand": busy["command"],+                    "elapsedSeconds": elapsed,+                    "requestId": busy["request_id"],+                }+                if walk:+                    resp["walkProgress"] = walk+                elif busy["command"] in ("walk_cloud_tree", "search_cloud_files") and elapsed > 5:+                    # Walk/search was dispatched (HTTP thread set _busy_state) but+                    # the main thread hasn't started the handler yet (_walk_progress+                    # is still None). This means the custom event is queued but the+                    # main thread's event loop isn't processing it — typically+                    # because a modal dialog has its own nested event loop.+                    resp["mainThreadStalled"] = True+                    resp["stalledHint"] = (+                        "Command dispatched but main thread has not started processing. "+                        "A modal dialog is likely blocking Fusion's event loop. "+                        "Dismiss dialogs via fusion_dismiss_blocking_dialogs, "+                        "then the command will auto-resume."+                    )+                self._respond(200, resp)+            else:+                self._respond(200, {"busy": False})+        else:+            self._respond(404, {"error": "Not found"})++    def do_POST(self):+        if self.path != "/command":+            self._respond(404, {"error": "Not found"})+            return++        content_length = int(self.headers.get("Content-Length", 0))+        body = self.rfile.read(content_length)++        try:+            request = json.loads(body)+        except json.JSONDecodeError as e:+            self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})+            return++        command = request.get("command", "")+        args = request.get("args", {})+        request_id = str(uuid.uuid4())++        # Per-command timeout+        timeout = PER_COMMAND_TIMEOUT.get(command, COMMAND_TIMEOUT)+        # Allow caller override (useful for very large models)+        caller_timeout = request.get("timeout")+        if isinstance(caller_timeout, (int, float)) and caller_timeout > 0:+            timeout = float(caller_timeout)++        # Serialize CustomEvent fires through the main-thread lock so+        # requests don't stack. If another request is already in flight+        # to the main thread, we block here. We bound the wait so a+        # wedged main thread doesn't pile up indefinitely.+        lock_wait = timeout  # give us up to `timeout` seconds to acquire+        acquired = _main_thread_lock.acquire(timeout=lock_wait)+        if not acquired:+            busy = _get_busy()+            walk = get_walk_progress()+            elapsed = round(time.time() - busy["started_at"], 1) if busy else None+            error_msg = (+                f"Main thread busy — {busy['command']} running for {elapsed}s."+                if busy else+                f"Main thread busy — lock held for >{lock_wait}s."+            )+            resp = {+                "success": False,+                "error": error_msg,+                "errorCode": "main_thread_busy",+                "busyCommand": busy["command"] if busy else None,+                "elapsedSeconds": elapsed,+                "_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, "+                    "fusion_click_fusion, fusion_send_key, fusion_close_window."+                ),+            }+            if walk:+                resp["walkProgress"] = walk+            self._respond(200, resp)+            return++        try:+            _set_busy(command, request_id)++            # Track as pending before firing+            with _lock:+                _pending_request_ids.add(request_id)++            # Fire custom event to marshal to main thread+            try:+                _app.fireCustomEvent(+                    _custom_event_id,+                    json.dumps({"request_id": request_id, "command": command, "args": args}),+                )+            except Exception as e:+                with _lock:+                    _pending_request_ids.discard(request_id)+                self._respond(500, {"success": False, "error": f"Failed to fire event: {e}"})+                return++            # Wait for the main thread handler to deliver the result+            result = _wait_for_result(request_id, timeout=timeout)+            self._respond(200, result)+        finally:+            _clear_busy()+            _main_thread_lock.release()++    def _respond(self, status: int, data: dict):+        self.send_response(status)+        self.send_header("Content-Type", "application/json")+        self.end_headers()+        self.wfile.write(json.dumps(data).encode("utf-8"))++    def log_message(self, format, *args):+        # Suppress default HTTP logging to avoid cluttering Fusion's console+        pass+++def start(custom_event_id: str, app: adsk.core.Application) -> threading.Thread:+    """Start the HTTP server on a background daemon thread."""+    global _server, _server_thread, _custom_event_id, _app, _watchdog_thread++    _custom_event_id = custom_event_id+    _app = app++    # ThreadingHTTPServer so /health and concurrent requests don't block+    # behind a slow request. Main-thread work is still serialized via+    # _main_thread_lock + CustomEvent marshaling.+    _server = ThreadingHTTPServer(("127.0.0.1", PORT), AdomBridgeHandler)+    _server.daemon_threads = True++    _server_thread = threading.Thread(target=_server.serve_forever, daemon=True)+    _server_thread.start()++    # Start stale-lock watchdog (see _watchdog_loop docstring for rationale)+    _watchdog_stop.clear()+    _watchdog_thread = threading.Thread(target=_watchdog_loop, daemon=True, name="AdomBridge-Watchdog")+    _watchdog_thread.start()++    return _server_thread+++def stop():+    """Stop the HTTP server."""+    global _server+    _watchdog_stop.set()+    if _server:+        _server.shutdown()+        _server = None
addin/AdomBridge/resources/cam/jlcpcb-2layers.camadded+245
@@ -0,0 +1,245 @@+{+    "author": {+        "email": "[email protected]",+        "name": "JLCPCB"+    },+    "description": {+        "EN": "<b>JLCPCB 2-Layer CAM job</b><p>\n\nFor Eagle 8.6.0 to 9.6.2<p>\n\nTop Copper:      pcbname.GTL<br>\nTop Solder Mask: pcbname.GTS<br>\nTop Silkscreen:  pcbname.GTO<br>\nTop Paste:       pcbname.GTP<p>\n\nBottom Copper:      pcbname.GBL<br>\nBottom Solder Mask: pcbname.GBS<br>\nBottom Silkscreen:  pcbname.GBO<br>\nBottom Paste:       pcbname.GBP<p>\n\nProfile: pcbname.GKO (Dimension (20) and Milling (46) layers are used)<p>\nDrill Layer: pcbname.XLN (Both plated and non-plated holes are merged in this file)<p>\n\nPlease refer to <a href=\"https://support.jlcpcb.com/article/43-how-to-export-eagle-pcb-to-gerber-files\">How to Generate Gerber and Drill Files from Eagle</a> for more information.<p>\n\nEmail: [email protected]<p>\nLast updated: 2020/11/18"+    },+    "output_type": "zip",+    "outputs": [+        {+            "compress_output": true,+            "export_cad_netlist": true,+            "export_eda_component_attributes": true,+            "export_eda_data": true,+            "export_eda_footprint_names": true,+            "export_eda_net_attributes": true,+            "export_job_name_instead_of_product_model_name": true,+            "export_layer_features_count": true,+            "export_neutral_net_names": false,+            "export_step_profile_board_cutouts": true,+            "export_step_profile_from_board_shape": true,+            "export_text_attributes": true,+            "export_unique_id": true,+            "export_unit": true,+            "filename_prefix": "CAMOutputs/ODBFiles",+            "generate_job_file": true,+            "odb_format_version": "V8.1+",+            "output_name": "emmamorris_v11",+            "output_type": "odb++",+            "outputs": [+            ],+            "prepend_netattributes_with_numbersign": true,+            "use_teamhub_location": false+        },+        {+            "filename_prefix": "GerberJob",+            "format_specifier": {+                "decimal": 4,+                "integer": 3+            },+            "generate_job_file": true,+            "output_type": "gerber",+            "outputs": [+                {+                    "board_outline": true,+                    "config": {+                        "file_function": "Profile",+                        "plating": "non-plated"+                    },+                    "filename_format": "%N.GKO",+                    "layers": [+                        20+                    ],+                    "milling": true,+                    "name": "Board Outline",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Copper",+                        "layer": 1,+                        "layer_details": "mixed",+                        "layer_type": "top"+                    },+                    "filename_format": "%N.GTL",+                    "layers": [+                        1,+                        18,+                        17+                    ],+                    "name": "Top Copper",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Copper",+                        "layer": 2,+                        "layer_details": "mixed",+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N.GBL",+                    "layers": [+                        16,+                        17,+                        18+                    ],+                    "name": "Bottom Copper",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Soldermask",+                        "index": 1,+                        "layer_type": "top"+                    },+                    "filename_format": "%N.GTS",+                    "layers": [+                        29+                    ],+                    "name": "Soldermask Top",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Soldermask",+                        "index": 1,+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N.GBS",+                    "layers": [+                        30+                    ],+                    "name": "Soldermask Bottom",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Paste",+                        "layer_type": "top"+                    },+                    "filename_format": "%N.GTP",+                    "layers": [+                        31+                    ],+                    "milling": false,+                    "name": "Solderpaste Top",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Paste",+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N.GBP",+                    "layers": [+                        32+                    ],+                    "milling": false,+                    "name": "Solderpaste Bottom",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Legend",+                        "index": 1,+                        "layer_type": "top"+                    },+                    "filename_format": "%N.GTO",+                    "layers": [+                        21,+                        25+                    ],+                    "milling": false,+                    "name": "Silkscreen Top",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Legend",+                        "index": 1,+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N.GBO",+                    "layers": [+                        22,+                        26+                    ],+                    "milling": false,+                    "name": "Silkscreen Bottom",+                    "polarity": "positive",+                    "type": "gerber_layer"+                }+            ],+            "version": "RS274X"+        },+        {+            "extension_type": "pdf",+            "filename_prefix": "CAMOutputs/ImageFiles",+            "fit_to_Page": true,+            "monochrome": true,+            "output_type": "image",+            "outputs": [+            ],+            "page_size": "A4",+            "pdf_filename": "%N"+        },+        {+            "filename_prefix": "",+            "format_specifier": {+                "decimal": 3,+                "integer": 3+            },+            "output_type": "drill",+            "outputs": [+                {+                    "drills": {+                        "NPTH": true,+                        "PTH": true,+                        "VIA": true+                    },+                    "filename_format": "%N.XLN",+                    "layers": {+                        "from": 1,+                        "to": 16+                    },+                    "name": "Excellon",+                    "type": "excellon"+                }+            ]+        },+        {+            "filename_prefix": "CAMOutputs/Assembly",+            "output_type": "assembly",+            "outputs": [+            ]+        },+        {+            "filename_prefix": "CAMOutputs/DrawingFiles",+            "output_type": "drawing",+            "outputs": [+            ]+        }+    ],+    "timestamp": "2020-11-12T14:32:05",+    "type": "EAGLE CAM job",+    "units": "metric",+    "version": "9.2.0"+}
addin/AdomBridge/resources/cam/jlcpcb-4layers.camadded+262
@@ -0,0 +1,262 @@+{+    "author": {+        "email": "[email protected]",+        "name": "JLCPCB"+    },+    "description": {+        "EN": "<b>JLCPCB 4-Layer CAM job</b><p>\n\nFor Eagle 8.6.0 to 9.6.2<p>\n\nTop Copper:           pcbname_copper_l1.GTL<br>\nInner Copper Layer 1: pcbname_copper_l2.G1<br>\nInner Copper Layer 2: pcbname_copper_l3.G2<br>\nBottom Copper:        pcbname_copper_l4.GBL<p>\n\nDrill File: pcbname_drill.XLN (Both plated and non-plated holes are merged into this file)<p>\n\nSilkscreen Bottom: pcbname_Legend_Bot.GBO<br>\nSilkscreen Top:    pcbname_Legend_Top.GTO<br>\nPaste Bottom:      pcbname_Paste_Bot.GBP<br>\nPaste Top:         pcbname_Paste_Top.GTP<p>\n\nBoard Outline: pcbname_Profile_NP.GKO (Includes Dimension (20) and Milling (46) layers)<p>\n\nSoldermask Bottom: pcbname_Soldermask_Bot.GBS<br>\nSoldermask Top:    pcbname_Soldermask_Top.GTS<p>\n\nPlease refer to <a href=\"https://support.jlcpcb.com/article/43-how-to-export-eagle-pcb-to-gerber-files\">How to Generate Gerber and Drill Files from Eagle</a> for more information.<p>\n\nEmail: [email protected]<p>\nLast updated: 2020/11/18"+    },+    "output_type": "zip",+    "outputs": [+        {+            "filename_prefix": "GerberJob",+            "format_specifier": {+                "decimal": 4,+                "integer": 3+            },+            "generate_job_file": true,+            "output_type": "gerber",+            "outputs": [+                {+                    "board_outline": true,+                    "config": {+                        "file_function": "Profile",+                        "plating": "non-plated"+                    },+                    "filename_format": "%N_%FF.GKO",+                    "layers": [+                        46,+                        20+                    ],+                    "milling": true,+                    "name": "Board Outline",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Copper",+                        "layer": 1,+                        "layer_details": "mixed",+                        "layer_type": "top"+                    },+                    "filename_format": "%N_%FF.GTL",+                    "layers": [+                        1,+                        18,+                        17+                    ],+                    "name": "Top Copper",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "advanced_options": {+                        "mirror": false,+                        "offset_x": 0,+                        "offset_y": 0,+                        "rotate": false,+                        "upside_down": false+                    },+                    "board_outline": false,+                    "config": {+                        "file_function": "Copper",+                        "layer": 2,+                        "layer_details": "mixed",+                        "layer_type": "Inner"+                    },+                    "filename_format": "%N_%FF.G1",+                    "layers": [+                        2,+                        17,+                        18+                    ],+                    "milling": false,+                    "name": "Inner Copper Layer 1",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "advanced_options": {+                        "mirror": false,+                        "offset_x": 0,+                        "offset_y": 0,+                        "rotate": false,+                        "upside_down": false+                    },+                    "board_outline": false,+                    "config": {+                        "file_function": "Copper",+                        "layer": 3,+                        "layer_details": "mixed",+                        "layer_type": "Inner"+                    },+                    "filename_format": "%N_%FF.G2",+                    "layers": [+                        15,+                        17,+                        18+                    ],+                    "milling": false,+                    "name": "Inner Copper Layer 2",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Copper",+                        "layer": 4,+                        "layer_details": "mixed",+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N_%FF.GBL",+                    "layers": [+                        16,+                        17,+                        18+                    ],+                    "name": "Bottom Copper",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Soldermask",+                        "index": 1,+                        "layer_type": "top"+                    },+                    "filename_format": "%N_%FF.GTS",+                    "layers": [+                        29+                    ],+                    "name": "Soldermask Top",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Soldermask",+                        "index": 1,+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N_%FF.GBS",+                    "layers": [+                        30+                    ],+                    "name": "Soldermask Bottom",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Paste",+                        "layer_type": "top"+                    },+                    "filename_format": "%N_%FF.GTP",+                    "layers": [+                        31+                    ],+                    "milling": false,+                    "name": "Solderpaste Top",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Paste",+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N_%FF.GBP",+                    "layers": [+                        32+                    ],+                    "milling": false,+                    "name": "Solderpaste Bottom",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Legend",+                        "index": 1,+                        "layer_type": "top"+                    },+                    "filename_format": "%N_%FF.GTO",+                    "layers": [+                        21,+                        25+                    ],+                    "milling": false,+                    "name": "Silkscreen Top",+                    "polarity": "positive",+                    "type": "gerber_layer"+                },+                {+                    "board_outline": false,+                    "config": {+                        "file_function": "Legend",+                        "index": 1,+                        "layer_type": "bottom"+                    },+                    "filename_format": "%N_%FF.GBO",+                    "layers": [+                        22,+                        26+                    ],+                    "milling": false,+                    "name": "Silkscreen Bottom",+                    "polarity": "positive",+                    "type": "gerber_layer"+                }+            ],+            "version": "RS274X"+        },+        {+            "filename_prefix": "",+            "format_specifier": {+                "decimal": 3,+                "integer": 3+            },+            "output_type": "drill",+            "outputs": [+                {+                    "drills": {+                        "NPTH": true,+                        "PTH": true,+                        "VIA": true+                    },+                    "filename_format": "%N_drill.XLN",+                    "layers": {+                        "from": 1,+                        "to": 16+                    },+                    "name": "Drill File",+                    "type": "excellon"+                }+            ]+        },+        {+            "filename_prefix": "CAMOutputs/Assembly",+            "output_type": "assembly",+            "outputs": [+            ]+        },+        {+            "filename_prefix": "CAMOutputs/DrawingFiles",+            "output_type": "drawing",+            "outputs": [+            ]+        }+    ],+    "timestamp": "2020-11-18T10:32:27",+    "type": "EAGLE CAM job",+    "units": "metric",+    "version": "9.2.0"+}
addin/AdomBridge/resources/dru/Adom-InstaPCB-2-Layer.edruadded+165
@@ -0,0 +1,165 @@+<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE designrules SYSTEM "eagle.dtd">+<eagle version="9.7.0">+<designrules name="Adom-InstaPCB-2-Layer.edru">+<classes>+<class number="0" name="default">+</class>+<class number="1" name="pwr">+</class>+</classes>+<rules>+<rulecategory type="Copper Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Keepout Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Solder-Mask Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Dimension Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Drill Distance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Drill Size" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Copper Width" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Acute Angle" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Air-Wire" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Small Gap" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Wire Stub" manualdrc="yes" livedrc="no"/>+ <rulecategory type="Matched Lengths" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Via Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rule type="Minimum Copper Width" enabled="yes" priority="0" onescope="classes=1" value="0.1mm" preferredvalue="0.3mm" check_polywidth="no" name="Trace size for Power"/>+ <rule type="Minimum Copper Width" builtin_ruleid="14" enabled="yes" priority="1" value="0.1mm" preferredvalue="0.2mm" check_polywidth="no" name="minimum-copper-width-1"/>+ <rule type="Minimum Drill Size" enabled="yes" priority="0" onescope="is_via" value="0.15mm" preferredvalue="0.3mm" name="Min Via hole size on 2-layer"/>+ <rule type="Minimum Drill Size" enabled="no" priority="1" onescope="all" value="0.5mm" preferredvalue="0.5mm" name="Min Non-plated holes"/>+ <rule type="Minimum Drill Size" builtin_ruleid="15" enabled="yes" priority="2" value="0.15mm" preferredvalue="0.3mm" name="minimum-drill-size-1"/>+ <rule type="Acute Angle" builtin_ruleid="16" enabled="no" priority="0" name="acute-angle-1"/>+ <rule type="Wire Stub" builtin_ruleid="17" enabled="yes" priority="0" name="wire-stub-1"/>+ <rule type="Air-Wire" builtin_ruleid="19" enabled="yes" priority="0" name="air-wire-1"/>+ <rule type="Matched Lengths" builtin_ruleid="20" enabled="yes" priority="0" tolerance="10mm" gapfactor="2.5" name="matched-lengths-1"/>+ <rule type="Copper Clearance" enabled="no" priority="0" onescope="all" otherscope="all" value="0.25mm" preferredvalue="0.25mm" samesignal="yes" bothscope="all" name="Same-net track spacing"/>+ <rule type="Copper Clearance" builtin_ruleid="0" enabled="yes" priority="1" onescope="is_wire_polygon" otherscope="is_wire_polygon" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-1"/>+ <rule type="Copper Clearance" builtin_ruleid="1" enabled="yes" priority="2" onescope="is_wire_polygon" otherscope="is_pad_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-2"/>+ <rule type="Copper Clearance" builtin_ruleid="2" enabled="yes" priority="3" onescope="is_wire_polygon" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-3"/>+ <rule type="Copper Clearance" builtin_ruleid="3" enabled="yes" priority="4" onescope="is_pad_smd" otherscope="is_pad_smd" value="0.15mm" preferredvalue="0.15mm" samesignal="no" name="copper-clearance-4"/>+ <rule type="Copper Clearance" builtin_ruleid="4" enabled="yes" priority="5" onescope="is_pad_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-5"/>+ <rule type="Copper Clearance" builtin_ruleid="5" enabled="yes" priority="6" onescope="is_via" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-6"/>+ <rule type="Copper Clearance" builtin_ruleid="6" enabled="yes" priority="7" onescope="is_smd" otherscope="is_pad" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-7"/>+ <rule type="Copper Clearance" builtin_ruleid="7" enabled="yes" priority="8" onescope="is_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-8"/>+ <rule type="Copper Clearance" builtin_ruleid="8" enabled="yes" priority="9" onescope="is_smd" otherscope="is_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-9"/>+ <rule type="Dimension Clearance" builtin_ruleid="9" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.4mm" name="dimension-clearance-1"/>+ <rule type="Via Restrict Clearance" builtin_ruleid="21" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="via-restrict-clearance-1"/>+ <rule type="Restrict Clearance" builtin_ruleid="10" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="restrict-clearance-1"/>+ <rule type="Small Gap" builtin_ruleid="18" enabled="yes" priority="0" name="small-gap-1"/>+ <rule type="Solder-Mask Clearance" builtin_ruleid="11" enabled="no" priority="0" value="0mil" preferredvalue="0mil" check_name="yes" check_value="no" name="solder-mask-clearance-1"/>+ <rule type="Drill Distance" builtin_ruleid="12" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.2mm" name="drill-distance-1"/>+ <rule type="Keepout Clearance" builtin_ruleid="13" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="keepout-clearance-1"/>+ </rules>+<param name="mdWireWire" value="0.1mm"/>+<param name="mdWirePad" value="0.1mm"/>+<param name="mdWireVia" value="0.1mm"/>+<param name="mdPadPad" value="0.15mm"/>+<param name="mdPadVia" value="0.1mm"/>+<param name="mdViaVia" value="0.1mm"/>+<param name="mdSmdPad" value="0.1mm"/>+<param name="mdSmdVia" value="0.1mm"/>+<param name="mdSmdSmd" value="0.1mm"/>+<param name="mdCopperDimension" value="0.2mm"/>+<param name="mdDrill" value="0.2mm"/>+<param name="msWidth" value="0.1mm"/>+<param name="msDrill" value="0.15mm"/>+<param name="dpMaxLengthDifference" value="10mm"/>+<param name="dpGapFactor" value="2.5"/>+<description language="en">Adom InstaPCB 2-Layer design rules++Board thickness: 1.6mm+Copper weight: 1oz (35um)++There are 2 net classes of:+These are valuable when using the auto-router if you tell it what wires are in the Power class+- Default (Min 0.1mm track width, Preferred 0.2mm)+- Power (Min 0.1mm track width, Preferred 0.3mm)+Min. track width and spacing (1 oz)+0.10 / 0.10 mm (4 / 4 mil)++Vias:+- Min 0.15mm via allowed on 2-layer now with InstaPCB+- We made preferred 0.25mm, although InstaPCB says 0.2 is preferred+Min. Via hole size/diameter 2-layer: 0.15mm hole size / 0.25mm via diameter++Drill:+Drill Diameter - 2-layer: 0.15 – 6.3 mm+Min. Non-plated holes - 0.50mm+Holes with diameter ≥ 6.3 mm are CNC routed from a smaller drilled hole.++Annular Ring:+Vias: 0.1mm annular ring is InstaPCB min+Pads (through-hole parts): 0.18mm annular ring is InstaPCB min+However, they prefer 0.25mm annular ring or above++</description>+<param name="layerSetup" value="(1*16)"/>+<param name="mtCopper" value="0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm"/>+<param name="mtIsolate" value="1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm"/>+<param name="mdViaViaSameLayer" value="6mil"/>+<param name="mnLayersViaInSmd" value="2"/>+<param name="mdSmdStop" value="0mil"/>+<param name="msMicroVia" value="0.3mm"/>+<param name="msBlindViaRatio" value="0.5"/>+<param name="rvPadTop" value="0"/>+<param name="rvPadInner" value="0"/>+<param name="rvPadBottom" value="0"/>+<param name="rvViaOuter" value="0"/>+<param name="rvViaInner" value="0"/>+<param name="rvMicroViaOuter" value="0"/>+<param name="rvMicroViaInner" value="0"/>+<param name="rlMinPadTop" value="0.18mm"/>+<param name="rlMaxPadTop" value="80mil"/>+<param name="rlMinPadInner" value="0.18mm"/>+<param name="rlMaxPadInner" value="20mil"/>+<param name="rlMinPadBottom" value="0.18mm"/>+<param name="rlMaxPadBottom" value="80mil"/>+<param name="rlMinViaOuter" value="0.1mm"/>+<param name="rlMaxViaOuter" value="20mil"/>+<param name="rlMinViaInner" value="0.1mm"/>+<param name="rlMaxViaInner" value="20mil"/>+<param name="rlMinMicroViaOuter" value="0.1mm"/>+<param name="rlMaxMicroViaOuter" value="20mil"/>+<param name="rlMinMicroViaInner" value="0.1mm"/>+<param name="rlMaxMicroViaInner" value="20mil"/>+<param name="psTop" value="-1"/>+<param name="psBottom" value="-1"/>+<param name="psFirst" value="-1"/>+<param name="psElongationLong" value="100"/>+<param name="psElongationOffset" value="100"/>+<param name="mvStopFrame" value="0"/>+<param name="mvCreamFrame" value="0"/>+<param name="mlMinStopFrame" value="0.038mm"/>+<param name="mlMaxStopFrame" value="4mil"/>+<param name="mlMinCreamFrame" value="0mil"/>+<param name="mlMaxCreamFrame" value="0mil"/>+<param name="mlViaStopLimit" value="0.5mm"/>+<param name="srRoundness" value="0"/>+<param name="srMinRoundness" value="0mil"/>+<param name="srMaxRoundness" value="0mil"/>+<param name="slThermalIsolate" value="10mil"/>+<param name="slThermalsForVias" value="0"/>+<param name="checkAngle" value="0"/>+<param name="checkRestrict" value="1"/>+<param name="checkStop" value="0"/>+<param name="checkValues" value="0"/>+<param name="checkNames" value="1"/>+<param name="checkWireStubs" value="1"/>+<param name="checkPolygonWidth" value="0"/>+<param name="useDiameter" value="13"/>+<param name="removeAnnularRing" value="0"/>+<param name="maxErrors" value="50"/>+<layerstackup name="Default Layer Stack" type="Standard Stackup" display_unit="mic" roughness_model="0">+ <layerdef type="Signal" id="{5a9a536d-8422-4e27-a88e-724a65556bb4}" layer="1" name="Top" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Core" id="{4559ea01-630e-4693-8c35-7a0c3eba0b8a}" layer="1" name="Dielectric-1" locally_modified_material="yes">+ <material name="User Defined" construction="" thickness="1.6mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{77284764-ed3d-425b-85b1-529ddeb5fc5a}" layer="16" name="Bottom" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <viadef name="Thru 1:16" startlayer="1" stoplayer="16" complement="no"/>+ </layerstackup>+ </designrules>+</eagle>
addin/AdomBridge/resources/dru/Adom-InstaPCB-4-Layer.edruadded+183
@@ -0,0 +1,183 @@+<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE designrules SYSTEM "eagle.dtd">+<eagle version="9.7.0">+<designrules name="Adom-InstaPCB-4-Layer.edru">+<classes>+<class number="0" name="default">+</class>+<class number="1" name="pwr">+</class>+</classes>+<rules>+<rulecategory type="Copper Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Keepout Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Solder-Mask Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Dimension Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Drill Distance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Drill Size" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Copper Width" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Acute Angle" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Air-Wire" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Small Gap" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Wire Stub" manualdrc="yes" livedrc="no"/>+ <rulecategory type="Matched Lengths" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Via Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rule type="Minimum Copper Width" enabled="yes" priority="0" onescope="classes=1" value="0.09mm" preferredvalue="0.3mm" check_polywidth="no" name="Trace size for Power"/>+ <rule type="Minimum Copper Width" builtin_ruleid="14" enabled="yes" priority="1" value="0.09mm" preferredvalue="0.2mm" check_polywidth="no" name="minimum-copper-width-1"/>+ <rule type="Minimum Drill Size" enabled="yes" priority="0" onescope="is_via" value="0.15mm" preferredvalue="0.2mm" name="Min Via hole size on 4-layer"/>+ <rule type="Minimum Drill Size" enabled="no" priority="1" onescope="all" value="0.5mm" preferredvalue="0.5mm" name="Min Non-plated holes"/>+ <rule type="Minimum Drill Size" builtin_ruleid="15" enabled="yes" priority="2" value="0.15mm" preferredvalue="0.25mm" name="minimum-drill-size-1"/>+ <rule type="Acute Angle" builtin_ruleid="16" enabled="no" priority="0" name="acute-angle-1"/>+ <rule type="Wire Stub" builtin_ruleid="17" enabled="yes" priority="0" name="wire-stub-1"/>+ <rule type="Air-Wire" builtin_ruleid="19" enabled="yes" priority="0" name="air-wire-1"/>+ <rule type="Matched Lengths" builtin_ruleid="20" enabled="yes" priority="0" tolerance="10mm" gapfactor="2.5" name="matched-lengths-1"/>+ <rule type="Copper Clearance" enabled="yes" priority="0" onescope="all" otherscope="all" value="0.25mm" preferredvalue="0.25mm" samesignal="yes" bothscope="all" name="Same-net track spacing"/>+ <rule type="Copper Clearance" builtin_ruleid="0" enabled="yes" priority="1" onescope="is_wire_polygon" otherscope="is_wire_polygon" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-1"/>+ <rule type="Copper Clearance" builtin_ruleid="1" enabled="yes" priority="2" onescope="is_wire_polygon" otherscope="is_pad_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-2"/>+ <rule type="Copper Clearance" builtin_ruleid="2" enabled="yes" priority="3" onescope="is_wire_polygon" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-3"/>+ <rule type="Copper Clearance" builtin_ruleid="3" enabled="yes" priority="4" onescope="is_pad_smd" otherscope="is_pad_smd" value="0.15mm" preferredvalue="0.15mm" samesignal="no" name="copper-clearance-4"/>+ <rule type="Copper Clearance" builtin_ruleid="4" enabled="yes" priority="5" onescope="is_pad_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-5"/>+ <rule type="Copper Clearance" builtin_ruleid="5" enabled="yes" priority="6" onescope="is_via" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-6"/>+ <rule type="Copper Clearance" builtin_ruleid="6" enabled="yes" priority="7" onescope="is_smd" otherscope="is_pad" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-7"/>+ <rule type="Copper Clearance" builtin_ruleid="7" enabled="yes" priority="8" onescope="is_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-8"/>+ <rule type="Copper Clearance" builtin_ruleid="8" enabled="yes" priority="9" onescope="is_smd" otherscope="is_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-9"/>+ <rule type="Dimension Clearance" builtin_ruleid="9" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.2mm" name="dimension-clearance-1"/>+ <rule type="Via Restrict Clearance" builtin_ruleid="21" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="via-restrict-clearance-1"/>+ <rule type="Restrict Clearance" builtin_ruleid="10" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="restrict-clearance-1"/>+ <rule type="Small Gap" builtin_ruleid="18" enabled="yes" priority="0" name="small-gap-1"/>+ <rule type="Solder-Mask Clearance" builtin_ruleid="11" enabled="no" priority="0" value="0mil" preferredvalue="0mil" check_name="yes" check_value="no" name="solder-mask-clearance-1"/>+ <rule type="Drill Distance" builtin_ruleid="12" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.2mm" name="drill-distance-1"/>+ <rule type="Keepout Clearance" builtin_ruleid="13" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="keepout-clearance-1"/>+ </rules>+<param name="mdWireWire" value="0.1mm"/>+<param name="mdWirePad" value="0.1mm"/>+<param name="mdWireVia" value="0.1mm"/>+<param name="mdPadPad" value="0.15mm"/>+<param name="mdPadVia" value="0.1mm"/>+<param name="mdViaVia" value="0.1mm"/>+<param name="mdSmdPad" value="0.1mm"/>+<param name="mdSmdVia" value="0.1mm"/>+<param name="mdSmdSmd" value="0.1mm"/>+<param name="mdCopperDimension" value="0.2mm"/>+<param name="mdDrill" value="0.2mm"/>+<param name="msWidth" value="0.1mm"/>+<param name="msDrill" value="0.15mm"/>+<param name="dpMaxLengthDifference" value="10mm"/>+<param name="dpGapFactor" value="2.5"/>+<description language="en">Adom InstaPCB 4-Layer design rules++Board thickness: 1.6mm+Copper weight: 1oz (35um)++Track width spacing for 4-layer:+Multilayer: 0.09 / 0.09 mm (3.5 / 3.5 mil). 3 mil is acceptable in BGA fan-outs.+BGA pad diameter ≥ 0.25 mm+BGA pad to trace clearance min. 0.09 mm for multilayer boards+Vias can be placed within BGA pads using filled and plated-over vias++There are 2 net classes of:+These are valuable when using the auto-router if you tell it what wires are in the Power class+- Default (Min 0.09mm track width, Preferred 0.2mm)+- Power (Min 0.09mm track width, Preferred 0.3mm)+Min. track width and spacing (1 oz)+0.10 / 0.10 mm (4 / 4 mil)++Vias:+- Min 0.15mm via allowed on 4-layer now with InstaPCB+- We made preferred 0.25mm, although InstaPCB says 0.2 is preferred+Min. Via hole size/diameter 4-layer: 0.15mm hole size / 0.25mm via diameter++Drill:+Drill Diameter - 4-layer: 0.15 – 6.3 mm+Min. Non-plated holes - 0.50mm+Holes with diameter ≥ 6.3 mm are CNC routed from a smaller drilled hole.++Annular Ring:+Vias: 0.1mm annular ring is InstaPCB min+Pads (through-hole parts): 0.18mm annular ring is InstaPCB min+However, they prefer 0.25mm annular ring or above++</description>+<param name="layerSetup" value="(1*16)"/>+<param name="mtCopper" value="0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm"/>+<param name="mtIsolate" value="1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm"/>+<param name="mdViaViaSameLayer" value="6mil"/>+<param name="mnLayersViaInSmd" value="2"/>+<param name="mdSmdStop" value="0mil"/>+<param name="msMicroVia" value="0.3mm"/>+<param name="msBlindViaRatio" value="0.5"/>+<param name="rvPadTop" value="0"/>+<param name="rvPadInner" value="0"/>+<param name="rvPadBottom" value="0"/>+<param name="rvViaOuter" value="0"/>+<param name="rvViaInner" value="0"/>+<param name="rvMicroViaOuter" value="0"/>+<param name="rvMicroViaInner" value="0"/>+<param name="rlMinPadTop" value="0.18mm"/>+<param name="rlMaxPadTop" value="80mil"/>+<param name="rlMinPadInner" value="0.18mm"/>+<param name="rlMaxPadInner" value="20mil"/>+<param name="rlMinPadBottom" value="0.18mm"/>+<param name="rlMaxPadBottom" value="80mil"/>+<param name="rlMinViaOuter" value="0.1mm"/>+<param name="rlMaxViaOuter" value="20mil"/>+<param name="rlMinViaInner" value="0.1mm"/>+<param name="rlMaxViaInner" value="20mil"/>+<param name="rlMinMicroViaOuter" value="0.1mm"/>+<param name="rlMaxMicroViaOuter" value="20mil"/>+<param name="rlMinMicroViaInner" value="0.1mm"/>+<param name="rlMaxMicroViaInner" value="20mil"/>+<param name="psTop" value="-1"/>+<param name="psBottom" value="-1"/>+<param name="psFirst" value="-1"/>+<param name="psElongationLong" value="100"/>+<param name="psElongationOffset" value="100"/>+<param name="mvStopFrame" value="0"/>+<param name="mvCreamFrame" value="0"/>+<param name="mlMinStopFrame" value="0.038mm"/>+<param name="mlMaxStopFrame" value="4mil"/>+<param name="mlMinCreamFrame" value="0mil"/>+<param name="mlMaxCreamFrame" value="0mil"/>+<param name="mlViaStopLimit" value="0.5mm"/>+<param name="srRoundness" value="0"/>+<param name="srMinRoundness" value="0mil"/>+<param name="srMaxRoundness" value="0mil"/>+<param name="slThermalIsolate" value="10mil"/>+<param name="slThermalsForVias" value="0"/>+<param name="checkAngle" value="0"/>+<param name="checkRestrict" value="1"/>+<param name="checkStop" value="0"/>+<param name="checkValues" value="0"/>+<param name="checkNames" value="1"/>+<param name="checkWireStubs" value="1"/>+<param name="checkPolygonWidth" value="0"/>+<param name="useDiameter" value="13"/>+<param name="removeAnnularRing" value="0"/>+<param name="maxErrors" value="50"/>+<layerstackup name="Default Layer Stack" type="Standard Stackup" display_unit="mic" roughness_model="0">+ <layerdef type="Signal" id="{aacc8861-39b2-48dc-8db3-f266478ef2b8}" layer="1" name="Top" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Prepreg" id="{adcb8555-47a8-402a-8dc6-0ded7ce8d84c}" layer="2" name="Dielectric-2" locally_modified_material="yes">+ <material name="User Defined" glass_style="" thickness="0.2mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{5ffaa838-d681-4063-bb48-2b039e6cf0b6}" layer="2" name="Route2" locally_modified_material="yes">+ <material name="User Defined" thickness="0.0175mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Core" id="{80a6f05f-0ad2-4112-8467-cea4561d9bb9}" layer="1" name="Dielectric-1" locally_modified_material="yes">+ <material name="User Defined" construction="" thickness="1.065mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{37bd35fe-018e-4cdb-8b86-5ed09ec84a8a}" layer="15" name="Route15" locally_modified_material="yes">+ <material name="User Defined" thickness="0.0175mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Prepreg" id="{61cdb834-2e83-4ba7-98a3-15b69ca17485}" layer="3" name="Dielectric-3" locally_modified_material="yes">+ <material name="User Defined" glass_style="" thickness="0.2mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{633df6dd-d049-4e1e-91be-c70449465674}" layer="16" name="Bottom" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <viadef name="Thru 1:16" startlayer="1" stoplayer="16" complement="no"/>+ </layerstackup>+ </designrules>+</eagle>
addin/AdomBridge/resources/dru/AdomDefaults-jlcpcb-2layers-accurate.edruadded+165
@@ -0,0 +1,165 @@+<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE designrules SYSTEM "eagle.dtd">+<eagle version="9.7.0">+<designrules name="AdomDefaults-jlcpcb-2layers-accurate.edru">+<classes>+<class number="0" name="default">+</class>+<class number="1" name="pwr">+</class>+</classes>+<rules>+<rulecategory type="Copper Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Keepout Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Solder-Mask Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Dimension Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Drill Distance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Drill Size" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Copper Width" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Acute Angle" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Air-Wire" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Small Gap" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Wire Stub" manualdrc="yes" livedrc="no"/>+ <rulecategory type="Matched Lengths" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Via Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rule type="Minimum Copper Width" enabled="yes" priority="0" onescope="classes=1" value="0.1mm" preferredvalue="0.3mm" check_polywidth="no" name="Trace size for Power"/>+ <rule type="Minimum Copper Width" builtin_ruleid="14" enabled="yes" priority="1" value="0.1mm" preferredvalue="0.2mm" check_polywidth="no" name="minimum-copper-width-1"/>+ <rule type="Minimum Drill Size" enabled="yes" priority="0" onescope="is_via" value="0.15mm" preferredvalue="0.3mm" name="Min Via hole size on 2-layer"/>+ <rule type="Minimum Drill Size" enabled="no" priority="1" onescope="all" value="0.5mm" preferredvalue="0.5mm" name="Min Non-plated holes"/>+ <rule type="Minimum Drill Size" builtin_ruleid="15" enabled="yes" priority="2" value="0.15mm" preferredvalue="0.3mm" name="minimum-drill-size-1"/>+ <rule type="Acute Angle" builtin_ruleid="16" enabled="no" priority="0" name="acute-angle-1"/>+ <rule type="Wire Stub" builtin_ruleid="17" enabled="yes" priority="0" name="wire-stub-1"/>+ <rule type="Air-Wire" builtin_ruleid="19" enabled="yes" priority="0" name="air-wire-1"/>+ <rule type="Matched Lengths" builtin_ruleid="20" enabled="yes" priority="0" tolerance="10mm" gapfactor="2.5" name="matched-lengths-1"/>+ <rule type="Copper Clearance" enabled="no" priority="0" onescope="all" otherscope="all" value="0.25mm" preferredvalue="0.25mm" samesignal="yes" bothscope="all" name="Same-net track spacing"/>+ <rule type="Copper Clearance" builtin_ruleid="0" enabled="yes" priority="1" onescope="is_wire_polygon" otherscope="is_wire_polygon" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-1"/>+ <rule type="Copper Clearance" builtin_ruleid="1" enabled="yes" priority="2" onescope="is_wire_polygon" otherscope="is_pad_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-2"/>+ <rule type="Copper Clearance" builtin_ruleid="2" enabled="yes" priority="3" onescope="is_wire_polygon" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-3"/>+ <rule type="Copper Clearance" builtin_ruleid="3" enabled="yes" priority="4" onescope="is_pad_smd" otherscope="is_pad_smd" value="0.15mm" preferredvalue="0.15mm" samesignal="no" name="copper-clearance-4"/>+ <rule type="Copper Clearance" builtin_ruleid="4" enabled="yes" priority="5" onescope="is_pad_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-5"/>+ <rule type="Copper Clearance" builtin_ruleid="5" enabled="yes" priority="6" onescope="is_via" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-6"/>+ <rule type="Copper Clearance" builtin_ruleid="6" enabled="yes" priority="7" onescope="is_smd" otherscope="is_pad" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-7"/>+ <rule type="Copper Clearance" builtin_ruleid="7" enabled="yes" priority="8" onescope="is_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-8"/>+ <rule type="Copper Clearance" builtin_ruleid="8" enabled="yes" priority="9" onescope="is_smd" otherscope="is_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-9"/>+ <rule type="Dimension Clearance" builtin_ruleid="9" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.4mm" name="dimension-clearance-1"/>+ <rule type="Via Restrict Clearance" builtin_ruleid="21" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="via-restrict-clearance-1"/>+ <rule type="Restrict Clearance" builtin_ruleid="10" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="restrict-clearance-1"/>+ <rule type="Small Gap" builtin_ruleid="18" enabled="yes" priority="0" name="small-gap-1"/>+ <rule type="Solder-Mask Clearance" builtin_ruleid="11" enabled="no" priority="0" value="0mil" preferredvalue="0mil" check_name="yes" check_value="no" name="solder-mask-clearance-1"/>+ <rule type="Drill Distance" builtin_ruleid="12" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.2mm" name="drill-distance-1"/>+ <rule type="Keepout Clearance" builtin_ruleid="13" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="keepout-clearance-1"/>+ </rules>+<param name="mdWireWire" value="0.1mm"/>+<param name="mdWirePad" value="0.1mm"/>+<param name="mdWireVia" value="0.1mm"/>+<param name="mdPadPad" value="0.15mm"/>+<param name="mdPadVia" value="0.1mm"/>+<param name="mdViaVia" value="0.1mm"/>+<param name="mdSmdPad" value="0.1mm"/>+<param name="mdSmdVia" value="0.1mm"/>+<param name="mdSmdSmd" value="0.1mm"/>+<param name="mdCopperDimension" value="0.2mm"/>+<param name="mdDrill" value="0.2mm"/>+<param name="msWidth" value="0.1mm"/>+<param name="msDrill" value="0.15mm"/>+<param name="dpMaxLengthDifference" value="10mm"/>+<param name="dpGapFactor" value="2.5"/>+<description language="en">Adom Defaults for JLCPCB design rules (2 layers)++Board thickness: 1.6mm+Copper weight: 1oz (35um)++There are 2 net classes of:+These are valuable when using the auto-router if you tell it what wires are in the Power class+- Default (Min 0.1mm track width, Preferred 0.2mm)+- Power (Min 0.1mm track width, Preferred 0.3mm)+Min. track width and spacing (1 oz)+0.10 / 0.10 mm (4 / 4 mil)++Vias:+- Min 0.15mm via allowed on 2-layer now with JLC+- We made preferred 0.25mm, although JLC says 0.2 is preferred+Min. Via hole size/diameter 2-layer: 0.15mm hole size / 0.25mm via diameter++Drill:+Drill Diameter - 2-layer: 0.15 – 6.3 mm+Min. Non-plated holes - 0.50mm+Holes with diameter ≥ 6.3 mm are CNC routed from a smaller drilled hole.++Annular Ring:+Vias: 0.1mm annular ring is JLC min+Pads (through-hole parts): 0.18mm annular ring is JLC min+However, they prefer 0.25mm annular ring or above++</description>+<param name="layerSetup" value="(1*16)"/>+<param name="mtCopper" value="0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm"/>+<param name="mtIsolate" value="1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm"/>+<param name="mdViaViaSameLayer" value="6mil"/>+<param name="mnLayersViaInSmd" value="2"/>+<param name="mdSmdStop" value="0mil"/>+<param name="msMicroVia" value="0.3mm"/>+<param name="msBlindViaRatio" value="0.5"/>+<param name="rvPadTop" value="0"/>+<param name="rvPadInner" value="0"/>+<param name="rvPadBottom" value="0"/>+<param name="rvViaOuter" value="0"/>+<param name="rvViaInner" value="0"/>+<param name="rvMicroViaOuter" value="0"/>+<param name="rvMicroViaInner" value="0"/>+<param name="rlMinPadTop" value="0.18mm"/>+<param name="rlMaxPadTop" value="80mil"/>+<param name="rlMinPadInner" value="0.18mm"/>+<param name="rlMaxPadInner" value="20mil"/>+<param name="rlMinPadBottom" value="0.18mm"/>+<param name="rlMaxPadBottom" value="80mil"/>+<param name="rlMinViaOuter" value="0.1mm"/>+<param name="rlMaxViaOuter" value="20mil"/>+<param name="rlMinViaInner" value="0.1mm"/>+<param name="rlMaxViaInner" value="20mil"/>+<param name="rlMinMicroViaOuter" value="0.1mm"/>+<param name="rlMaxMicroViaOuter" value="20mil"/>+<param name="rlMinMicroViaInner" value="0.1mm"/>+<param name="rlMaxMicroViaInner" value="20mil"/>+<param name="psTop" value="-1"/>+<param name="psBottom" value="-1"/>+<param name="psFirst" value="-1"/>+<param name="psElongationLong" value="100"/>+<param name="psElongationOffset" value="100"/>+<param name="mvStopFrame" value="0"/>+<param name="mvCreamFrame" value="0"/>+<param name="mlMinStopFrame" value="0.038mm"/>+<param name="mlMaxStopFrame" value="4mil"/>+<param name="mlMinCreamFrame" value="0mil"/>+<param name="mlMaxCreamFrame" value="0mil"/>+<param name="mlViaStopLimit" value="0.5mm"/>+<param name="srRoundness" value="0"/>+<param name="srMinRoundness" value="0mil"/>+<param name="srMaxRoundness" value="0mil"/>+<param name="slThermalIsolate" value="10mil"/>+<param name="slThermalsForVias" value="0"/>+<param name="checkAngle" value="0"/>+<param name="checkRestrict" value="1"/>+<param name="checkStop" value="0"/>+<param name="checkValues" value="0"/>+<param name="checkNames" value="1"/>+<param name="checkWireStubs" value="1"/>+<param name="checkPolygonWidth" value="0"/>+<param name="useDiameter" value="13"/>+<param name="removeAnnularRing" value="0"/>+<param name="maxErrors" value="50"/>+<layerstackup name="Default Layer Stack" type="Standard Stackup" display_unit="mic" roughness_model="0">+ <layerdef type="Signal" id="{5a9a536d-8422-4e27-a88e-724a65556bb4}" layer="1" name="Top" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Core" id="{4559ea01-630e-4693-8c35-7a0c3eba0b8a}" layer="1" name="Dielectric-1" locally_modified_material="yes">+ <material name="User Defined" construction="" thickness="1.6mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{77284764-ed3d-425b-85b1-529ddeb5fc5a}" layer="16" name="Bottom" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <viadef name="Thru 1:16" startlayer="1" stoplayer="16" complement="no"/>+ </layerstackup>+ </designrules>+</eagle>
addin/AdomBridge/resources/dru/AdomDefaults-jlcpcb-4layers-accurate.edruadded+183
@@ -0,0 +1,183 @@+<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE designrules SYSTEM "eagle.dtd">+<eagle version="9.7.0">+<designrules name="AdomDefaults-jlcpcb-4layers-accurate.edru">+<classes>+<class number="0" name="default">+</class>+<class number="1" name="pwr">+</class>+</classes>+<rules>+<rulecategory type="Copper Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Keepout Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Solder-Mask Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Dimension Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Drill Distance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Drill Size" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Minimum Copper Width" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Acute Angle" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Air-Wire" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Small Gap" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Wire Stub" manualdrc="yes" livedrc="no"/>+ <rulecategory type="Matched Lengths" manualdrc="yes" livedrc="yes"/>+ <rulecategory type="Via Restrict Clearance" manualdrc="yes" livedrc="yes"/>+ <rule type="Minimum Copper Width" enabled="yes" priority="0" onescope="classes=1" value="0.09mm" preferredvalue="0.3mm" check_polywidth="no" name="Trace size for Power"/>+ <rule type="Minimum Copper Width" builtin_ruleid="14" enabled="yes" priority="1" value="0.09mm" preferredvalue="0.2mm" check_polywidth="no" name="minimum-copper-width-1"/>+ <rule type="Minimum Drill Size" enabled="yes" priority="0" onescope="is_via" value="0.15mm" preferredvalue="0.2mm" name="Min Via hole size on 4-layer"/>+ <rule type="Minimum Drill Size" enabled="no" priority="1" onescope="all" value="0.5mm" preferredvalue="0.5mm" name="Min Non-plated holes"/>+ <rule type="Minimum Drill Size" builtin_ruleid="15" enabled="yes" priority="2" value="0.15mm" preferredvalue="0.25mm" name="minimum-drill-size-1"/>+ <rule type="Acute Angle" builtin_ruleid="16" enabled="no" priority="0" name="acute-angle-1"/>+ <rule type="Wire Stub" builtin_ruleid="17" enabled="yes" priority="0" name="wire-stub-1"/>+ <rule type="Air-Wire" builtin_ruleid="19" enabled="yes" priority="0" name="air-wire-1"/>+ <rule type="Matched Lengths" builtin_ruleid="20" enabled="yes" priority="0" tolerance="10mm" gapfactor="2.5" name="matched-lengths-1"/>+ <rule type="Copper Clearance" enabled="yes" priority="0" onescope="all" otherscope="all" value="0.25mm" preferredvalue="0.25mm" samesignal="yes" bothscope="all" name="Same-net track spacing"/>+ <rule type="Copper Clearance" builtin_ruleid="0" enabled="yes" priority="1" onescope="is_wire_polygon" otherscope="is_wire_polygon" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-1"/>+ <rule type="Copper Clearance" builtin_ruleid="1" enabled="yes" priority="2" onescope="is_wire_polygon" otherscope="is_pad_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-2"/>+ <rule type="Copper Clearance" builtin_ruleid="2" enabled="yes" priority="3" onescope="is_wire_polygon" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-3"/>+ <rule type="Copper Clearance" builtin_ruleid="3" enabled="yes" priority="4" onescope="is_pad_smd" otherscope="is_pad_smd" value="0.15mm" preferredvalue="0.15mm" samesignal="no" name="copper-clearance-4"/>+ <rule type="Copper Clearance" builtin_ruleid="4" enabled="yes" priority="5" onescope="is_pad_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-5"/>+ <rule type="Copper Clearance" builtin_ruleid="5" enabled="yes" priority="6" onescope="is_via" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="no" name="copper-clearance-6"/>+ <rule type="Copper Clearance" builtin_ruleid="6" enabled="yes" priority="7" onescope="is_smd" otherscope="is_pad" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-7"/>+ <rule type="Copper Clearance" builtin_ruleid="7" enabled="yes" priority="8" onescope="is_smd" otherscope="is_via" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-8"/>+ <rule type="Copper Clearance" builtin_ruleid="8" enabled="yes" priority="9" onescope="is_smd" otherscope="is_smd" value="0.1mm" preferredvalue="0.2mm" samesignal="yes" name="copper-clearance-9"/>+ <rule type="Dimension Clearance" builtin_ruleid="9" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.2mm" name="dimension-clearance-1"/>+ <rule type="Via Restrict Clearance" builtin_ruleid="21" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="via-restrict-clearance-1"/>+ <rule type="Restrict Clearance" builtin_ruleid="10" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="restrict-clearance-1"/>+ <rule type="Small Gap" builtin_ruleid="18" enabled="yes" priority="0" name="small-gap-1"/>+ <rule type="Solder-Mask Clearance" builtin_ruleid="11" enabled="no" priority="0" value="0mil" preferredvalue="0mil" check_name="yes" check_value="no" name="solder-mask-clearance-1"/>+ <rule type="Drill Distance" builtin_ruleid="12" enabled="yes" priority="0" value="0.2mm" preferredvalue="0.2mm" name="drill-distance-1"/>+ <rule type="Keepout Clearance" builtin_ruleid="13" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="keepout-clearance-1"/>+ </rules>+<param name="mdWireWire" value="0.1mm"/>+<param name="mdWirePad" value="0.1mm"/>+<param name="mdWireVia" value="0.1mm"/>+<param name="mdPadPad" value="0.15mm"/>+<param name="mdPadVia" value="0.1mm"/>+<param name="mdViaVia" value="0.1mm"/>+<param name="mdSmdPad" value="0.1mm"/>+<param name="mdSmdVia" value="0.1mm"/>+<param name="mdSmdSmd" value="0.1mm"/>+<param name="mdCopperDimension" value="0.2mm"/>+<param name="mdDrill" value="0.2mm"/>+<param name="msWidth" value="0.1mm"/>+<param name="msDrill" value="0.15mm"/>+<param name="dpMaxLengthDifference" value="10mm"/>+<param name="dpGapFactor" value="2.5"/>+<description language="en">Adom Defaults for JLCPCB design rules (4 layers)++Board thickness: 1.6mm+Copper weight: 1oz (35um)++Track width spacing for 4-layer:+Multilayer: 0.09 / 0.09 mm (3.5 / 3.5 mil). 3 mil is acceptable in BGA fan-outs.+BGA pad diameter ≥ 0.25 mm+BGA pad to trace clearance min. 0.09 mm for multilayer boards+Vias can be placed within BGA pads using filled and plated-over vias++There are 2 net classes of:+These are valuable when using the auto-router if you tell it what wires are in the Power class+- Default (Min 0.09mm track width, Preferred 0.2mm)+- Power (Min 0.09mm track width, Preferred 0.3mm)+Min. track width and spacing (1 oz)+0.10 / 0.10 mm (4 / 4 mil)++Vias:+- Min 0.15mm via allowed on 4-layer now with JLC+- We made preferred 0.25mm, although JLC says 0.2 is preferred+Min. Via hole size/diameter 4-layer: 0.15mm hole size / 0.25mm via diameter++Drill:+Drill Diameter - 4-layer: 0.15 – 6.3 mm+Min. Non-plated holes - 0.50mm+Holes with diameter ≥ 6.3 mm are CNC routed from a smaller drilled hole.++Annular Ring:+Vias: 0.1mm annular ring is JLC min+Pads (through-hole parts): 0.18mm annular ring is JLC min+However, they prefer 0.25mm annular ring or above++</description>+<param name="layerSetup" value="(1*16)"/>+<param name="mtCopper" value="0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm 0.035mm"/>+<param name="mtIsolate" value="1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm"/>+<param name="mdViaViaSameLayer" value="6mil"/>+<param name="mnLayersViaInSmd" value="2"/>+<param name="mdSmdStop" value="0mil"/>+<param name="msMicroVia" value="0.3mm"/>+<param name="msBlindViaRatio" value="0.5"/>+<param name="rvPadTop" value="0"/>+<param name="rvPadInner" value="0"/>+<param name="rvPadBottom" value="0"/>+<param name="rvViaOuter" value="0"/>+<param name="rvViaInner" value="0"/>+<param name="rvMicroViaOuter" value="0"/>+<param name="rvMicroViaInner" value="0"/>+<param name="rlMinPadTop" value="0.18mm"/>+<param name="rlMaxPadTop" value="80mil"/>+<param name="rlMinPadInner" value="0.18mm"/>+<param name="rlMaxPadInner" value="20mil"/>+<param name="rlMinPadBottom" value="0.18mm"/>+<param name="rlMaxPadBottom" value="80mil"/>+<param name="rlMinViaOuter" value="0.1mm"/>+<param name="rlMaxViaOuter" value="20mil"/>+<param name="rlMinViaInner" value="0.1mm"/>+<param name="rlMaxViaInner" value="20mil"/>+<param name="rlMinMicroViaOuter" value="0.1mm"/>+<param name="rlMaxMicroViaOuter" value="20mil"/>+<param name="rlMinMicroViaInner" value="0.1mm"/>+<param name="rlMaxMicroViaInner" value="20mil"/>+<param name="psTop" value="-1"/>+<param name="psBottom" value="-1"/>+<param name="psFirst" value="-1"/>+<param name="psElongationLong" value="100"/>+<param name="psElongationOffset" value="100"/>+<param name="mvStopFrame" value="0"/>+<param name="mvCreamFrame" value="0"/>+<param name="mlMinStopFrame" value="0.038mm"/>+<param name="mlMaxStopFrame" value="4mil"/>+<param name="mlMinCreamFrame" value="0mil"/>+<param name="mlMaxCreamFrame" value="0mil"/>+<param name="mlViaStopLimit" value="0.5mm"/>+<param name="srRoundness" value="0"/>+<param name="srMinRoundness" value="0mil"/>+<param name="srMaxRoundness" value="0mil"/>+<param name="slThermalIsolate" value="10mil"/>+<param name="slThermalsForVias" value="0"/>+<param name="checkAngle" value="0"/>+<param name="checkRestrict" value="1"/>+<param name="checkStop" value="0"/>+<param name="checkValues" value="0"/>+<param name="checkNames" value="1"/>+<param name="checkWireStubs" value="1"/>+<param name="checkPolygonWidth" value="0"/>+<param name="useDiameter" value="13"/>+<param name="removeAnnularRing" value="0"/>+<param name="maxErrors" value="50"/>+<layerstackup name="Default Layer Stack" type="Standard Stackup" display_unit="mic" roughness_model="0">+ <layerdef type="Signal" id="{aacc8861-39b2-48dc-8db3-f266478ef2b8}" layer="1" name="Top" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Prepreg" id="{adcb8555-47a8-402a-8dc6-0ded7ce8d84c}" layer="2" name="Dielectric-2" locally_modified_material="yes">+ <material name="User Defined" glass_style="" thickness="0.2mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{5ffaa838-d681-4063-bb48-2b039e6cf0b6}" layer="2" name="Route2" locally_modified_material="yes">+ <material name="User Defined" thickness="0.0175mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Core" id="{80a6f05f-0ad2-4112-8467-cea4561d9bb9}" layer="1" name="Dielectric-1" locally_modified_material="yes">+ <material name="User Defined" construction="" thickness="1.065mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{37bd35fe-018e-4cdb-8b86-5ed09ec84a8a}" layer="15" name="Route15" locally_modified_material="yes">+ <material name="User Defined" thickness="0.0175mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <layerdef type="Prepreg" id="{61cdb834-2e83-4ba7-98a3-15b69ca17485}" layer="3" name="Dielectric-3" locally_modified_material="yes">+ <material name="User Defined" glass_style="" thickness="0.2mm" resin="50%" dielectric_constant_1g="4.5" dissipation_factor_1g="0.02" glass_transition_temperature="125degC" source="User"/>+</layerdef>+ <layerdef type="Signal" id="{633df6dd-d049-4e1e-91be-c70449465674}" layer="16" name="Bottom" locally_modified_material="yes">+ <material name="User Defined" thickness="0.035mm" weight="1oz" process="Electro Deposited" source="User"/>+</layerdef>+ <viadef name="Thru 1:16" startlayer="1" stoplayer="16" complement="no"/>+ </layerstackup>+ </designrules>+</eagle>
addin/AdomBridge/resources/ulp/detect_layers.ulpadded+81
@@ -0,0 +1,81 @@+// ULP script to detect board layers+// Usage: run detect_layers.ulp output_file_path++if (argc < 1) {+    dlgMessageBox("Please provide an output file path!\nUsage: run detect_layers.ulp <output_file>");+    exit(1);+}++string output_file = argv[1];++if (board) {+    board(B) {+        int copper_layer_count = 0;+        string layer_info = "";+        +        // Simple approach: check for traces/wires on each layer+        int layer_has_traces[];+        +        // Check for traces on each layer+        B.signals(S) {+            S.wires(W) {+                layer_has_traces[W.layer] = 1;+            }+        }+        +        // Check all layers and determine which are copper layers+        B.layers(L) {+            int is_copper = 0;+            +            // More robust layer detection based on Eagle's standard layer numbering:+            // Layer 1: Top copper (always present)+            // Layer 16: Bottom copper (always present) +            // Layers 2-15: Inner copper layers (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)+            // Standard 4-layer uses: 1 (top), 2 (inner1), 15 (inner2), 16 (bottom)+            // Standard 6-layer uses: 1, 2, 3, 14, 15, 16+            // Standard 8-layer uses: 1, 2, 3, 4, 13, 14, 15, 16+            +            if (L.number == 1 || L.number == 16) {+                // Top and bottom layers - always copper if they exist+                is_copper = 1;+                copper_layer_count++;+            } else if (L.number >= 2 && L.number <= 15) {+                // Potential inner copper layers - only count if they have traces+                if (layer_has_traces[L.number]) {+                    is_copper = 1;+                    copper_layer_count++;+                }+            }+            +            // Add layer info to JSON+            if (layer_info != "") {+                layer_info += ",";+            }+            +            string layer_num, is_copper_str, has_traces_str;+            sprintf(layer_num, "%d", L.number);+            sprintf(is_copper_str, "%d", is_copper);+            sprintf(has_traces_str, "%d", layer_has_traces[L.number]);+            +            layer_info += "{\"number\":" + layer_num + ",\"name\":\"" + L.name + "\",\"isCopper\":" + is_copper_str + ",\"hasTraces\":" + has_traces_str + "}";+        }+        +        // Write results to file+        output(output_file) {+            printf("{\n");+            printf("  \"copperLayerCount\": %d,\n", copper_layer_count);+            printf("  \"boardName\": \"%s\",\n", B.name);+            printf("  \"layers\": [%s]\n", layer_info);+            printf("}\n");+        }+    }+} else {+    // No board found - write error info+    output(output_file) {+        printf("{\n");+        printf("  \"error\": \"No board found\",\n");+        printf("  \"copperLayerCount\": 0,\n");+        printf("  \"layers\": []\n");+        printf("}\n");+    }+}
describe.py+20−5
@@ -16,6 +16,10 @@ _T = [     # ── Lifecycle ──────────────────────────────────────────────────────────     ("fusion_start", "Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.",      {}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),+    ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",+     {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}),+    ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.",+     {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 30, None, False, {}),     ("fusion_close_window", "Close the active Fusion document/window.",      {"name": "optional str (default: active)"}, {"success": "bool"}, 30, None, False, {}),     ("fusion_get_app_state", "Current app state: running, active document, workspace, isElectronics. The poll/status verb for long-running calls.",@@ -33,7 +37,7 @@ _T = [      {"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 design directly by its APS/Fusion URN.",+    ("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"}),     ("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, {}),@@ -52,15 +56,17 @@ _T = [      {"projectId": "required str", "parentFolderId": "required str", "name": "required str"}, {"folderId": "str"}, 50, None, False, {"projectId": "a.YnVz...", "parentFolderId": "urn:...", "name": "new-folder"}),     ("fusion_aps_get", "Raw authenticated GET against a free APS path (dev/verify helper).",      {"path": "required str"}, {"status": "int", "body": "obj"}, 40, None, False, {"path": "/project/v1/hubs"}),-    ("fusion_walk_cloud_tree", "Slow in-app folder-by-folder cloud walk (legacy; prefer fusion_aps_search).",+    ("fusion_walk_cloud_tree", "DO NOT USE: slow in-app add-in walk (30+ min) that CAN CRASH Fusion (WinError 10054). Use fusion_aps_search instead; see the 'fusion-aps-search' skill.",      {}, {"tree": "obj"}, 600, _STATUS, True, {}),-    ("fusion_search_cloud_files", "Slow in-app cloud search (legacy; prefer fusion_aps_search).",+    ("fusion_search_cloud_files", "DO NOT USE: slow in-app add-in search (30+ min) that CAN CRASH Fusion (WinError 10054). Use fusion_aps_search instead; see the 'fusion-aps-search' skill.",      {"query": "required str"}, {"results": "[...]"}, 180, _STATUS, True, {"query": "Molecule"}),      # ── Electronics ────────────────────────────────────────────────────────-    ("fusion_show_2d_board", "Switch to the 2D PCB Editor (board layout) view.",+    ("fusion_show_schematic", "Switch the OPEN electronics design to its SCHEMATIC view (the symmetric partner of fusion_show_2d_board / fusion_show_3d_board, via Fusion's SwitchSchDocCmd). A VIEW of the project, not a standalone file. Drive it for demos with fusion_electron_zoom / _pan / _select.",+     {}, {"success": "bool", "activeWorkspace": "str", "productType": "str"}, 70, None, True, {}),+    ("fusion_show_2d_board", "Switch the OPEN electronics design to its 2D PCB Editor (board layout) view. This is a VIEW of the project, not a file to open standalone - if the board is empty, you opened a child/derivative instead of the EcadDesignProductType PROJECT; reopen the project. Drive the view with fusion_electron_run WINDOW commands.",      {}, {"activeWorkspace": "str"}, 70, None, True, {}),-    ("fusion_show_3d_board", "Switch to the 3D PCB view (populated board).",+    ("fusion_show_3d_board", "Switch the OPEN electronics design to its 3D PCB view (populated board). A VIEW of the project, not a standalone file. Orbit it for demos via fusion_run_modeling_script (app.activeViewport.camera + vp.refresh()).",      {}, {"activeWorkspace": "str", "screenshots": "[...]"}, 70, None, True, {}),     ("fusion_open_by_urn_note", "(see fusion_open_by_urn)", {}, {}, 30, None, False, {}),     ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.",@@ -100,6 +106,15 @@ _T = [      {}, {"dialogs": "[...]"}, 30, None, False, {}),     ("fusion_electron_run", "Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).",      {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN 'C:/tmp/jlcpcb_smta_exporter.ulp' 'C:/tmp/jlc'"}),+    ("fusion_electron_zoom", "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.",+     {"factor": "float (default 2.0)", "fit": "optional bool", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"},+     {"success": "bool", "output": "str"}, 60, None, True, {"factor": 3, "steps": 20}),+    ("fusion_electron_pan", "Smoothly PAN + zoom the view to frame a board-coordinate box (mm; get part positions from fusion_board_info). Animates from where the view last was.",+     {"x1": "float", "y1": "float", "x2": "float", "y2": "float", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"},+     {"success": "bool", "output": "str", "box": "[...]"}, 60, None, True, {"x1": 0, "y1": 0, "x2": 10, "y2": 8}),+    ("fusion_electron_select", "SELECT a part (by reference designator, or x/y board mm) and surface its properties - the way clicking a part reveals the properties panel. Great for showing life in a recording.",+     {"name": "str (refdes, e.g. R1)", "x": "optional float", "y": "optional float", "properties": "optional bool (default true)"},+     {"success": "bool", "output": "str", "deviceInfo": "str"}, 30, None, True, {"name": "U1"}),      # ── Self-describe ──────────────────────────────────────────────────────     ("fusion_describe", "Self-describe every verb the bridge exposes (this list) for AD's Verbs tab + runner.",
eagle_lbr.pyadded+270
@@ -0,0 +1,270 @@+"""Generate EAGLE .lbr (XML) library files for Fusion 360 Electronics.++Creates minimal but valid .lbr files that Fusion 360 can open via+Document.Open. The XML format matches EAGLE 9.7.0 as used by Fusion's+integrated Electronics workspace.++Usage:+    from eagle_lbr import build_lbr_xml, write_lbr++    # Build XML for a simple 2-pin resistor symbol+    xml = build_lbr_xml(+        library_name="MyLib",+        symbols=[{+            "name": "R",+            "description": "Resistor",+            "pins": [+                {"name": "1", "x": -5.08, "y": 0, "direction": "pas"},+                {"name": "2", "x": 5.08, "y": 0, "direction": "pas", "rot": "R180"},+            ],+            "wires": [+                {"x1": -2.54, "y1": -0.889, "x2": 2.54, "y2": -0.889, "width": 0.254, "layer": 94},+                {"x1": 2.54, "y1": 0.889, "x2": -2.54, "y2": 0.889, "width": 0.254, "layer": 94},+                {"x1": 2.54, "y1": -0.889, "x2": 2.54, "y2": 0.889, "width": 0.254, "layer": 94},+                {"x1": -2.54, "y1": -0.889, "x2": -2.54, "y2": 0.889, "width": 0.254, "layer": 94},+            ],+            "texts": [+                {"value": ">NAME", "x": 0, "y": 2.54, "size": 1.778, "layer": 95, "align": "center"},+                {"value": ">VALUE", "x": 0, "y": -2.54, "size": 1.778, "layer": 96, "align": "center"},+            ],+        }],+    )+    write_lbr(xml, "C:/tmp/MyLib.lbr")+"""++import xml.etree.ElementTree as ET+from xml.dom import minidom+++# Standard EAGLE layer definitions used by Fusion 360 Electronics.+# These are the layers from a real Fusion-generated library.+STANDARD_LAYERS = [+    (1, "Top"), (2, "Route2"), (3, "Route3"), (4, "Route4"),+    (5, "Route5"), (6, "Route6"), (7, "Route7"), (8, "Route8"),+    (9, "Route9"), (10, "Route10"), (11, "Route11"), (12, "Route12"),+    (13, "Route13"), (14, "Route14"), (15, "Route15"), (16, "Bottom"),+    (17, "Pads"), (18, "Vias"), (19, "Unrouted"), (20, "BoardOutline"),+    (21, "SilkscreenTop"), (22, "SilkscreenBottom"),+    (23, "OriginsTop"), (24, "OriginsBottom"),+    (25, "NamesTop"), (26, "NamesBottom"),+    (27, "ValuesTop"), (28, "ValuesBottom"),+    (29, "SolderMaskTop"), (30, "SolderMaskBottom"),+    (31, "StencilTop"), (32, "StencilBottom"),+    (33, "FinishTop"), (34, "FinishBottom"),+    (35, "GlueTop"), (36, "GlueBottom"),+    (37, "TestTop"), (38, "TestBottom"),+    (39, "ComponentExcludeTop"), (40, "ComponentExcludeBottom"),+    (41, "RestrictTop"), (42, "RestrictBottom"), (43, "RestrictVias"),+    (44, "Drills"), (45, "Holes"), (46, "Milling"),+    (47, "Measures"), (48, "Document"), (49, "Reference"),+    (51, "DocumentTop"), (52, "DocumentBottom"),+    (88, "SimResults"), (89, "SimProbes"),+    (90, "Modules"), (91, "Nets"), (92, "Busses"), (93, "Pins"),+    (94, "Symbols"), (95, "Names"), (96, "Values"),+    (97, "Info"), (98, "Guide"),+]+++def _add_layers(parent: ET.Element):+    """Add standard EAGLE layer definitions."""+    layers_el = ET.SubElement(parent, "layers")+    for num, name in STANDARD_LAYERS:+        attrs = {+            "number": str(num),+            "name": name,+            "blackcolor": "0xccc8c8c8",+            "whitecolor": "0xbb808080",+            "coloredcolor": "0xbb808080",+            "blackhighlight": "0xccc8c8c8",+            "whitehighlight": "0xbb808080",+            "coloredhighlight": "0xbb808080",+            "fill": "1",+            "visible": "yes",+            "active": "yes",+        }+        # Use Fusion's actual colors for key schematic layers+        if num == 94:  # Symbols+            attrs.update({+                "blackcolor": "0xccc90d0c", "whitecolor": "0xccc90d0c",+                "coloredcolor": "0xccc90d0c",+                "blackhighlight": "0xccc90000", "whitehighlight": "0xccc90000",+                "coloredhighlight": "0xccc90000",+            })+        elif num == 93:  # Pins+            attrs.update({+                "blackcolor": "0xcc3ddc97", "whitecolor": "0xcc0dab76",+                "coloredcolor": "0xcc0dab76",+                "blackhighlight": "0xcc00dc79", "whitehighlight": "0xcc00ab6f",+                "coloredhighlight": "0xcc00ab6f",+            })+        ET.SubElement(layers_el, "layer", **attrs)+++def _build_symbol_element(sym: dict) -> ET.Element:+    """Build a <symbol> element from a symbol dict.++    Symbol dict keys:+        name: str - symbol name (required)+        description: str - optional description+        pins: list of pin dicts+        wires: list of wire dicts+        texts: list of text dicts+        circles: list of circle dicts+        rectangles: list of rect dicts+    """+    el = ET.Element("symbol", name=sym["name"])++    if sym.get("description"):+        desc = ET.SubElement(el, "description")+        desc.text = sym["description"]++    for wire in sym.get("wires", []):+        attrs = {+            "x1": str(wire["x1"]), "y1": str(wire["y1"]),+            "x2": str(wire["x2"]), "y2": str(wire["y2"]),+            "width": str(wire.get("width", 0.254)),+            "layer": str(wire.get("layer", 94)),+        }+        ET.SubElement(el, "wire", **attrs)++    for circle in sym.get("circles", []):+        attrs = {+            "x": str(circle["x"]), "y": str(circle["y"]),+            "radius": str(circle["radius"]),+            "width": str(circle.get("width", 0.254)),+            "layer": str(circle.get("layer", 94)),+        }+        ET.SubElement(el, "circle", **attrs)++    for rect in sym.get("rectangles", []):+        attrs = {+            "x1": str(rect["x1"]), "y1": str(rect["y1"]),+            "x2": str(rect["x2"]), "y2": str(rect["y2"]),+            "layer": str(rect.get("layer", 94)),+        }+        ET.SubElement(el, "rectangle", **attrs)++    for pin in sym.get("pins", []):+        attrs = {"name": pin["name"]}+        attrs["x"] = str(pin.get("x", 0))+        attrs["y"] = str(pin.get("y", 0))+        if pin.get("visible"):+            attrs["visible"] = pin["visible"]+        else:+            attrs["visible"] = "off"+        attrs["length"] = pin.get("length", "short")+        if pin.get("direction"):+            attrs["direction"] = pin["direction"]+        if pin.get("swaplevel"):+            attrs["swaplevel"] = str(pin["swaplevel"])+        if pin.get("rot"):+            attrs["rot"] = pin["rot"]+        ET.SubElement(el, "pin", **attrs)++    for text in sym.get("texts", []):+        attrs = {+            "x": str(text.get("x", 0)),+            "y": str(text.get("y", 0)),+            "size": str(text.get("size", 1.778)),+            "layer": str(text.get("layer", 95)),+        }+        if text.get("align"):+            attrs["align"] = text["align"]+        t = ET.SubElement(el, "text", **attrs)+        t.text = text.get("value", "")++    return el+++def build_lbr_xml(+    library_name: str = "AdomLib",+    description: str = "",+    symbols: list[dict] | None = None,+) -> str:+    """Build a complete EAGLE .lbr XML string.++    Args:+        library_name: Library name (appears in EAGLE's library manager).+        description: Optional library description.+        symbols: List of symbol dicts. Each symbol dict has:+            - name: str (required)+            - description: str+            - pins: list of {name, x, y, direction?, rot?, visible?, length?}+            - wires: list of {x1, y1, x2, y2, width?, layer?}+            - texts: list of {value, x, y, size?, layer?, align?}+            - circles: list of {x, y, radius, width?, layer?}+            - rectangles: list of {x1, y1, x2, y2, layer?}++    Returns:+        Complete XML string ready to write to a .lbr file.+    """+    symbols = symbols or []++    root = ET.Element("eagle", version="9.7.0")+    drawing = ET.SubElement(root, "drawing")++    # Settings+    settings = ET.SubElement(drawing, "settings")+    ET.SubElement(settings, "setting", alwaysvectorfont="no")+    ET.SubElement(settings, "setting", verticaltext="up")++    # Grid+    ET.SubElement(drawing, "grid",+                  distance="0.1", unitdist="inch", unit="inch",+                  style="lines", multiple="1", display="no",+                  altdistance="0.01", altunitdist="inch", altunit="inch")++    # Layers+    _add_layers(drawing)++    # Library+    library = ET.SubElement(drawing, "library")++    if description:+        desc_el = ET.SubElement(library, "description")+        desc_el.text = description++    # Packages (empty — symbols only for now)+    ET.SubElement(library, "packages")++    # Symbols+    symbols_el = ET.SubElement(library, "symbols")+    for sym in symbols:+        symbols_el.append(_build_symbol_element(sym))++    # Devicesets (one per symbol, minimal — just gates mapping)+    devicesets_el = ET.SubElement(library, "devicesets")+    for sym in symbols:+        ds = ET.SubElement(devicesets_el, "deviceset",+                           name=sym["name"], prefix=sym.get("prefix", "U"))+        if sym.get("description"):+            ds_desc = ET.SubElement(ds, "description")+            ds_desc.text = sym["description"]+        gates = ET.SubElement(ds, "gates")+        ET.SubElement(gates, "gate", name="G$1", symbol=sym["name"],+                      x="0", y="0")+        ET.SubElement(ds, "devices")++    # Compatibility notes+    compat = ET.SubElement(root, "compatibility")+    note = ET.SubElement(compat, "note", version="8.3", severity="warning")+    note.text = ("Since Version 8.3, EAGLE supports URNs for individual library "+                 "assets (packages, symbols, and devices). The URNs of those assets "+                 "will not be understood (or retained) with this version.")++    # Pretty-print+    xml_str = ET.tostring(root, encoding="unicode", xml_declaration=False)+    dom = minidom.parseString(xml_str)+    pretty = dom.toprettyxml(indent="  ", encoding=None)+    # Remove the extra xml declaration minidom adds+    lines = pretty.split("\n")+    if lines[0].startswith("<?xml"):+        lines[0] = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+    return "\n".join(lines)+++def write_lbr(xml: str, path: str) -> str:+    """Write an LBR XML string to a file. Returns the path."""+    with open(path, "w", encoding="utf-8") as f:+        f.write(xml)+    return path
examples/jst_connector_generator.pyadded+130
@@ -0,0 +1,130 @@+"""Parametric JST connector generator — runs via fusion_run_modeling_script.++Builds a JST-family through-hole, top-entry header from a family table + pin count:+housing, shrouded mating cavity, the friction-lock window (polarization), and N+square pins. Dimensions are created as editable Fusion user parameters; the solid+is generated from those values (re-run to regenerate at new settings).++Configure by setting CONNECTOR before running (the bridge verb can prepend it):+    CONNECTOR = {"family": "XH", "pins": 4}+Supported families (pitch mm): SH 1.0 · GH 1.25 · ZH 1.5 · PH 2.0 · XH 2.5++Globals provided by the verb: adsk, app, ui, design.+"""++import adsk.core+import adsk.fusion++MM = 0.1  # Fusion internal length unit is cm++# family -> nominal dims (mm)+FAMILIES = {+    "SH": dict(pitch=1.00, bodyW=2.90, bodyH=4.25, wall=0.40, floor=0.50, end=0.90, pin=0.30, pinLen=3.0),+    "GH": dict(pitch=1.25, bodyW=3.00, bodyH=4.25, wall=0.45, floor=0.50, end=0.95, pin=0.30, pinLen=3.0),+    "ZH": dict(pitch=1.50, bodyW=3.50, bodyH=4.50, wall=0.50, floor=0.60, end=1.00, pin=0.40, pinLen=3.2),+    "PH": dict(pitch=2.00, bodyW=4.50, bodyH=4.50, wall=0.60, floor=0.60, end=1.35, pin=0.50, pinLen=3.4),+    "XH": dict(pitch=2.50, bodyW=5.75, bodyH=5.75, wall=0.70, floor=0.70, end=1.45, pin=0.64, pinLen=3.4),+}++cfg = dict(globals().get("CONNECTOR", {}))+fam = str(cfg.get("family", "PH")).upper()+if fam not in FAMILIES:+    raise ValueError(f"Unknown JST family '{fam}'. Choose from {list(FAMILIES)}.")+pins = max(1, int(cfg.get("pins", 2)))+d = FAMILIES[fam]+length_mm = (pins - 1) * d["pitch"] + 2 * d["end"]+part_name = f"JST-{fam} {pins}-pin"++root = design.rootComponent+extrudes = root.features.extrudeFeatures+planes = root.constructionPlanes++# --- editable user parameters (parametric in the Fusion UI) ---+up = design.userParameters+def _param(name, mm_val, comment):+    ex = up.itemByName(name)+    if ex:+        ex.expression = f"{mm_val} mm"+    else:+        up.add(name, adsk.core.ValueInput.createByString(f"{mm_val} mm"), "mm", comment)+for nm, val, cm in [("jst_pitch", d["pitch"], "Pin pitch"), ("jst_pin", d["pin"], "Pin square size"),+                    ("jst_pinLen", d["pinLen"], "Pin length below housing"), ("jst_bodyH", d["bodyH"], "Housing height"),+                    ("jst_bodyW", d["bodyW"], "Housing width"), ("jst_wall", d["wall"], "Shroud wall"),+                    ("jst_length", length_mm, "Housing length (derived)")]:+    _param(nm, val, cm)++L, W, H = length_mm * MM, d["bodyW"] * MM, d["bodyH"] * MM+wall, floor = d["wall"] * MM, d["floor"] * MM++def _rect(sketch, cx, cy, w, h):+    p0 = adsk.core.Point3D.create(cx - w / 2, cy - h / 2, 0)+    p1 = adsk.core.Point3D.create(cx + w / 2, cy + h / 2, 0)+    sketch.sketchCurves.sketchLines.addTwoPointRectangle(p0, p1)+    return sketch.profiles.item(0)++# 1) Housing solid.+sk_body = root.sketches.add(root.xYConstructionPlane)+housing = extrudes.addSimple(_rect(sk_body, 0, 0, L, W), adsk.core.ValueInput.createByReal(H),+                             adsk.fusion.FeatureOperations.NewBodyFeatureOperation)+housing_body = housing.bodies.item(0)+housing_body.name = "housing"++# 2) Shrouded mating cavity (inset, cut down from the top, leaving a floor).+plane_in = planes.createInput()+plane_in.setByOffset(root.xYConstructionPlane, adsk.core.ValueInput.createByReal(H))+top_plane = planes.add(plane_in)+sk_pocket = root.sketches.add(top_plane)+cut_in = extrudes.createInput(_rect(sk_pocket, 0, 0, L - 2 * wall, W - 2 * wall),+                              adsk.fusion.FeatureOperations.CutFeatureOperation)+cut_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-(H - floor)))+cut_in.participantBodies = [housing_body]+extrudes.add(cut_in)++# 3) Friction-lock window through the front wall (polarization / mating feature).+made_window = False+try:+    front_in = planes.createInput()+    front_in.setByOffset(root.xZConstructionPlane, adsk.core.ValueInput.createByReal(-W / 2))+    front_plane = planes.add(front_in)+    sk_win = root.sketches.add(front_plane)+    win_w, win_h = L * 0.55, H * 0.42+    # sketch plane X->model X, sketch Y->model Z; raise the window toward the top.+    win = extrudes.createInput(_rect(sk_win, 0, H * 0.55, win_w, win_h),+                               adsk.fusion.FeatureOperations.CutFeatureOperation)+    win.setDistanceExtent(False, adsk.core.ValueInput.createByReal(wall * 1.2))+    win.participantBodies = [housing_body]+    extrudes.add(win)+    made_window = True+except Exception as _e:+    print("note: lock window skipped:", _e)++# 4) Pins: one square post extruded down, then rectangular-patterned across the row.+first_x = -((pins - 1) * d["pitch"] * MM) / 2.0+sk_pin = root.sketches.add(root.xYConstructionPlane)+pin_in = extrudes.createInput(_rect(sk_pin, first_x, 0, d["pin"] * MM, d["pin"] * MM),+                              adsk.fusion.FeatureOperations.NewBodyFeatureOperation)+pin_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-(d["pinLen"] * MM)))+pin_feat = extrudes.add(pin_in)+pin_feat.bodies.item(0).name = "pin"+if pins > 1:+    patterns = root.features.rectangularPatternFeatures+    ents = adsk.core.ObjectCollection.create()+    ents.add(pin_feat)+    pat_in = patterns.createInput(ents, root.xConstructionAxis,+                                  adsk.core.ValueInput.createByReal(pins),+                                  adsk.core.ValueInput.createByReal((pins - 1) * d["pitch"] * MM),+                                  adsk.fusion.PatternDistanceType.ExtentPatternDistanceType)+    pat_in.quantityTwo = adsk.core.ValueInput.createByReal(1)+    pat_in.distanceTwo = adsk.core.ValueInput.createByReal(0)+    patterns.add(pat_in)++try:+    app.activeViewport.fit()+except Exception:+    pass++result = {"part": part_name, "family": fam, "pins": pins, "pitch_mm": d["pitch"],+          "length_mm": round(length_mm, 3), "bodies": root.bRepBodies.count, "lockWindow": made_window}+print(f"Built {part_name}: {pins}p @ {d['pitch']}mm, housing "+      f"{length_mm:.2f}x{d['bodyW']}x{d['bodyH']}mm, {root.bRepBodies.count} bodies"+      f"{' + lock window' if made_window else ''}.")
examples/jst_ph_connector.pyadded+115
@@ -0,0 +1,115 @@+"""Parametric JST-PH (2.0 mm) connector — runs via fusion_run_modeling_script.++Demonstrates the in-app modeling verb: builds a recognizable, parametric JST-PH+style top-entry connector (housing + shrouded mating cavity + N square pins) from+code, in the live Fusion session. FREE (no APS Fusion Automation billing).++Pin count: set `JST_PINS` in the exec namespace before running (the bridge verb+can prepend e.g. `JST_PINS = 4`). Defaults to 2. All dimensions are real JST-PH+nominal values, created as editable Fusion user parameters; the solid is then+generated from those parameter values (re-run to regenerate at new values).++Globals provided by the verb: adsk, app, ui, design.+"""++import adsk.core+import adsk.fusion++MM = 0.1  # Fusion internal length unit is cm; 1 mm = 0.1 cm++# --- parameters (JST-PH 2.0 mm nominal), in mm ---+pins = int(globals().get("JST_PINS", 2))+pitch_mm = 2.0+pin_mm = 0.5     # square pin cross-section+pin_len_mm = 3.4  # pin length below the housing+body_h_mm = 4.5   # housing height+body_w_mm = 4.5   # housing width (across the short axis)+wall_mm = 0.6     # shroud wall thickness+floor_mm = 0.6    # cavity floor thickness+end_mm = 1.35     # housing margin past the outer pins+length_mm = (pins - 1) * pitch_mm + 2 * end_mm++root = design.rootComponent+part_name = f"JST-PH {pins}-pin"  # root component can't be renamed; used for labels++# --- editable user parameters (so the model is parametric in the UI) ---+up = design.userParameters+def _param(name, mm_val, comment):+    existing = up.itemByName(name)+    vi = adsk.core.ValueInput.createByString(f"{mm_val} mm")+    if existing:+        existing.expression = f"{mm_val} mm"+        return existing+    return up.add(name, vi, "mm", comment)++_param("jst_pitch", pitch_mm, "Pin pitch")+_param("jst_pin", pin_mm, "Pin square size")+_param("jst_pinLen", pin_len_mm, "Pin length below housing")+_param("jst_bodyH", body_h_mm, "Housing height")+_param("jst_bodyW", body_w_mm, "Housing width")+_param("jst_wall", wall_mm, "Shroud wall thickness")+_param("jst_length", length_mm, "Housing length (derived)")++L = length_mm * MM+W = body_w_mm * MM+H = body_h_mm * MM+sk_planes = root.constructionPlanes+extrudes = root.features.extrudeFeatures++def _rect(sketch, cx, cy, w, h):+    p0 = adsk.core.Point3D.create(cx - w / 2, cy - h / 2, 0)+    p1 = adsk.core.Point3D.create(cx + w / 2, cy + h / 2, 0)+    sketch.sketchCurves.sketchLines.addTwoPointRectangle(p0, p1)+    return sketch.profiles.item(0)++# 1) Housing solid: centered box, extruded up by H.+sk_body = root.sketches.add(root.xYConstructionPlane)+prof_body = _rect(sk_body, 0, 0, L, W)+housing = extrudes.addSimple(prof_body, adsk.core.ValueInput.createByReal(H),+                             adsk.fusion.FeatureOperations.NewBodyFeatureOperation)+housing_body = housing.bodies.item(0)+housing_body.name = "housing"++# 2) Shroud cavity: inset rectangle on the top face, cut downward leaving a floor.+plane_in = sk_planes.createInput()+plane_in.setByOffset(root.xYConstructionPlane, adsk.core.ValueInput.createByReal(H))+top_plane = sk_planes.add(plane_in)+sk_pocket = root.sketches.add(top_plane)+prof_pocket = _rect(sk_pocket, 0, 0, L - 2 * wall_mm * MM, W - 2 * wall_mm * MM)+cut_in = extrudes.createInput(prof_pocket, adsk.fusion.FeatureOperations.CutFeatureOperation)+cut_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-(H - floor_mm * MM)))+cut_in.participantBodies = [housing_body]+extrudes.add(cut_in)++# 3) One pin: square post at the first pin location, extruded DOWN by pin_len.+first_x = -((pins - 1) * pitch_mm * MM) / 2.0+sk_pin = root.sketches.add(root.xYConstructionPlane)+prof_pin = _rect(sk_pin, first_x, 0, pin_mm * MM, pin_mm * MM)+pin_in = extrudes.createInput(prof_pin, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)+pin_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-(pin_len_mm * MM)))+pin_feat = extrudes.add(pin_in)+pin_feat.bodies.item(0).name = "pin"++# 4) Rectangular-pattern the pin N times along X at the pitch.+if pins > 1:+    patterns = root.features.rectangularPatternFeatures+    ents = adsk.core.ObjectCollection.create()+    ents.add(pin_feat)+    qty = adsk.core.ValueInput.createByReal(pins)+    total = adsk.core.ValueInput.createByReal((pins - 1) * pitch_mm * MM)+    pat_in = patterns.createInput(ents, root.xConstructionAxis, qty, total,+                                  adsk.fusion.PatternDistanceType.ExtentPatternDistanceType)+    pat_in.quantityTwo = adsk.core.ValueInput.createByReal(1)+    pat_in.distanceTwo = adsk.core.ValueInput.createByReal(0)+    patterns.add(pat_in)++# Fit the camera so a screenshot shows the part.+try:+    app.activeViewport.fit()+except Exception:+    pass++result = {"part": part_name, "pins": pins, "pitch_mm": pitch_mm,+          "length_mm": round(length_mm, 3), "bodies": root.bRepBodies.count}+print(f"Built {part_name}: {pins} pins @ {pitch_mm}mm pitch, "+      f"housing {length_mm:.2f}x{body_w_mm}x{body_h_mm}mm, {root.bRepBodies.count} bodies.")
fusion_detect.pyadded+290
@@ -0,0 +1,290 @@+"""Auto-detect Fusion 360 installation on Windows."""++import ctypes+import ctypes.wintypes+import json+import os+import subprocess+import time+import urllib.error+import urllib.request+from pathlib import Path++ADDIN_PORT = 8774++# Fusion 360 uses a web-deploy model — the launcher lives under+# %LOCALAPPDATA%\Autodesk\webdeploy\production\<hash>\FusionLauncher.exe+WEBDEPLOY_BASE = Path(os.environ.get("LOCALAPPDATA", "")) / "Autodesk" / "webdeploy" / "production"++# Add-ins can live in either of these directories (varies by Fusion version)+ADDINS_DIR_CANDIDATES = [+    Path(os.environ.get("APPDATA", "")) / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",+    Path(os.environ.get("APPDATA", "")) / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",+]++# Keep callback references alive to prevent GC+_callbacks = []++# Guard windll so this module imports on non-Windows hosts (galliaApril smoke+# tests, macOS). The Win32-only functions below are never reached off-Windows —+# detect_fusion() short-circuits to installed:False — but the bridge must still+# boot + serve /status on any platform AD spawns it on.+user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None+++def detect_fusion() -> dict:+    """Detect Fusion 360 installation. Returns a dict with paths and status."""+    exe_path = _find_fusion_launcher()+    addins_dir = _find_addins_dir()+    addin_installed = _check_addin_installed(addins_dir) if addins_dir else False+    running = _is_fusion_running()++    if exe_path:+        return {+            "installed": True,+            "exe_path": str(exe_path),+            "addins_dir": str(addins_dir) if addins_dir else None,+            "addin_installed": addin_installed,+            "running": running,+        }++    return {+        "installed": False,+        "exe_path": None,+        "addins_dir": str(addins_dir) if addins_dir else None,+        "addin_installed": addin_installed,+        "running": running,+        "error": "Fusion 360 not found",+    }+++def _find_fusion_launcher() -> Path | None:+    """Find FusionLauncher.exe under the webdeploy production directory.++    Scans subdirectories sorted by modification time (newest first).+    """+    if not WEBDEPLOY_BASE.exists():+        return None++    try:+        subdirs = sorted(+            [d for d in WEBDEPLOY_BASE.iterdir() if d.is_dir()],+            key=lambda d: d.stat().st_mtime,+            reverse=True,+        )+    except OSError:+        return None++    for subdir in subdirs:+        launcher = subdir / "FusionLauncher.exe"+        ini_file = subdir / "FusionLauncher.exe.ini"+        if launcher.exists() and ini_file.exists():+            return launcher++    return None+++def _find_addins_dir() -> Path | None:+    """Find the Fusion 360 add-ins directory."""+    for candidate in ADDINS_DIR_CANDIDATES:+        if candidate.exists():+            return candidate+    # Return the first candidate even if it doesn't exist yet (for install_addin)+    return ADDINS_DIR_CANDIDATES[0] if ADDINS_DIR_CANDIDATES else None+++def _check_addin_installed(addins_dir: Path | None) -> bool:+    """Check if the AdomBridge add-in is installed."""+    if not addins_dir:+        return False+    addin_dir = addins_dir / "AdomBridge"+    return (addin_dir / "AdomBridge.py").exists()+++def _is_fusion_running() -> bool:+    """Check if Fusion 360 is running (process check is authoritative).++    Uses tasklist instead of window title matching to avoid false positives+    from browser tabs that mention 'Autodesk Fusion' in their title.+    """+    return _has_fusion_process()+++def _has_fusion_process() -> bool:+    """Check if any Fusion 360 process is running via tasklist."""+    try:+        # CREATE_NO_WINDOW (0x08000000) on Windows so the child cmd window doesn't+        # flash on screen for every poll. The Tauri side spawns the Python bridge+        # itself with CREATE_NO_WINDOW, but Python's subprocess.run/Popen creates+        # a fresh console window for each child by default — without this flag,+        # every fusion_running check would briefly show a black cmd window.+        creationflags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0+        if hasattr(subprocess, "CREATE_NO_WINDOW"):+            creationflags = subprocess.CREATE_NO_WINDOW+        # stdin=DEVNULL is load-bearing: AD spawns the bridge console-less ++        # detached, so the inherited stdin handle is invalid. Without an explicit+        # DEVNULL, this child can block on a dead handle and deadlock the probe —+        # the exact failure mode that bit the KiCad bridge.+        output = subprocess.check_output(+            ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],+            stdin=subprocess.DEVNULL,+            stderr=subprocess.DEVNULL,+            text=True,+            timeout=5,+            creationflags=creationflags,+        )+        # tasklist returns "INFO: No tasks..." when no match+        return "Fusion360.exe" in output+    except Exception:+        return False+++def _find_windows_by_title(substring: str) -> list:+    """Find all visible windows whose title contains the given substring."""+    results = []+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            length = user32.GetWindowTextLengthW(hwnd)+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                if substring in buf.value:+                    results.append((hwnd, buf.value))+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return results+++def is_fusion_running() -> bool:+    """Public API: check if Fusion 360 is currently running (live check)."""+    return _has_fusion_process()+++def wait_for_addin(timeout: int = 90, dismiss_dialogs: bool = False) -> bool:+    """Wait for the AdomBridge add-in HTTP server to become responsive.++    Polls the add-in health endpoint once per second up to `timeout` seconds.+    Returns True if the add-in responded, False on timeout.++    If dismiss_dialogs is True, also checks for and dismisses blocking dialogs+    (startup picker, recovery dialog) every 5 seconds while waiting. This is+    critical at startup: dialogs block the add-in's main thread, so dismissing+    them is required for the add-in to become responsive.+    """+    for i in range(timeout):+        try:+            req = urllib.request.Request(+                f"http://127.0.0.1:{ADDIN_PORT}/health", method="GET"+            )+            with urllib.request.urlopen(req, timeout=1) as resp:+                return True+        except Exception:+            # Every 5 seconds during the wait, try to dismiss blocking dialogs.+            # Dialogs are the #1 reason the add-in can't respond at startup.+            if dismiss_dialogs and i >= 5 and i % 5 == 0:+                _dismiss_startup_dialogs()+            time.sleep(1)+    return False+++def _dismiss_startup_dialogs():+    """Dismiss common Fusion startup dialogs (recovery, startup picker).++    Uses Win32 APIs directly — no add-in needed. Called during wait_for_addin+    to unblock the main thread so the add-in can become responsive.+    """+    try:+        from handlers.dismiss_recovery import dismiss_recovery_dialog+        dismiss_recovery_dialog(max_wait=2.0)+    except Exception:+        pass++    # Dismiss startup picker ("What do you want to design?") and other+    # Fusion dialogs by sending Escape to the Fusion main window.+    try:+        from handlers.fusion_ui import send_key_to_fusion+        send_key_to_fusion("escape")+    except Exception:+        pass+++def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | None:+    """Launch Fusion 360 if not already running.++    Uses a live process check (not the stale fusion_info["running"] flag).+    Returns None on success, or an error dict on failure.+    If wait_addin is True, also waits for the add-in to be responsive.+    """+    # Live check — ignore stale fusion_info["running"]+    if _has_fusion_process():+        if wait_addin:+            # Fusion is running but add-in might not be ready yet+            if not wait_for_addin(timeout=10):+                return {+                    "success": False,+                    "error": "Fusion 360 is running but AdomBridge add-in not responding. "+                             "Enable it: UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run",+                }+        return None  # Already running (and add-in ready if requested)++    exe_path = fusion_info.get("exe_path")+    if not exe_path or not Path(exe_path).exists():+        return {"success": False, "error": "Fusion 360 executable not found"}++    try:+        # Explicit DEVNULL on all three std streams + CREATE_NO_WINDOW: the bridge+        # is itself a detached, console-less child of AD, so inherited handles are+        # invalid. Passing them through to the Fusion launcher risks a hang.+        _cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0+        subprocess.Popen(+            [exe_path],+            stdin=subprocess.DEVNULL,+            stdout=subprocess.DEVNULL,+            stderr=subprocess.DEVNULL,+            creationflags=_cnw,+        )+    except Exception as e:+        return {"success": False, "error": f"Failed to launch Fusion 360: {e}"}++    # Wait for Fusion process to appear (up to 30s for launcher to spawn main process)+    for _ in range(60):+        time.sleep(0.5)+        if _has_fusion_process():+            break+    else:+        return {+            "success": False,+            "error": "Fusion 360 launched but process not found after 30s",+        }++    # Give it time to finish initializing UI+    time.sleep(5.0)++    if wait_addin:+        # dismiss_dialogs=True: while waiting for the add-in, also check for+        # and dismiss blocking dialogs every 5s. This is critical because+        # startup dialogs (picker, recovery) block the add-in's main thread.+        if not wait_for_addin(timeout=60, dismiss_dialogs=True):+            return {+                "success": False,+                "error": "addin_timeout_after_launch",+                "message": "Fusion 360 launched but the AdomBridge add-in didn't respond "+                           "within 60s. A modal dialog (e.g. Document Recovery, update prompt) "+                           "may be blocking startup. Check Fusion and dismiss any dialogs, "+                           "then retry.",+            }++    return None+++if __name__ == "__main__":+    info = detect_fusion()+    print(json.dumps(info, indent=2))
fusion_helper.pyadded+382
@@ -0,0 +1,382 @@+#!/usr/bin/env python3+"""Fusion 360 helper utilities — start, stop, status, force-kill.++Provides reliable OS-level management of Fusion 360 processes on Windows.+Works independently of the bridge server or add-in.++Usage:+    python fusion_helper.py status      # Show Fusion process status+    python fusion_helper.py start       # Launch Fusion 360+    python fusion_helper.py stop        # Graceful stop (WM_CLOSE)+    python fusion_helper.py kill        # Force kill all Fusion processes+    python fusion_helper.py restart     # Kill + start+    python fusion_helper.py addin       # Check if add-in is responding+"""++import ctypes+import ctypes.wintypes+import json+import subprocess+import sys+import time+import urllib.request+import urllib.error+from pathlib import Path++# Import fusion_detect from same directory+sys.path.insert(0, str(Path(__file__).parent))+from fusion_detect import detect_fusion++ADDIN_PORT = 8774+BRIDGE_PORT = 8773++# Win32 constants+WM_CLOSE = 0x0010+PROCESS_TERMINATE = 0x0001+PROCESS_QUERY_INFORMATION = 0x0400++EnumWindows = ctypes.windll.user32.EnumWindows+GetWindowTextW = ctypes.windll.user32.GetWindowTextW+GetWindowTextLengthW = ctypes.windll.user32.GetWindowTextLengthW+PostMessageW = ctypes.windll.user32.PostMessageW+IsWindowVisible = ctypes.windll.user32.IsWindowVisible+GetWindowThreadProcessId = ctypes.windll.user32.GetWindowThreadProcessId++WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.wintypes.BOOL, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)+++def _get_fusion_processes() -> list[dict]:+    """Get all running Fusion-related processes using tasklist."""+    result = []+    try:+        output = subprocess.check_output(+            ["tasklist", "/FO", "CSV", "/NH"],+            stderr=subprocess.DEVNULL,+            text=True,+        )+        fusion_names = {"Fusion360.exe", "FusionLauncher.exe", "Fusion.exe",+                        "FusionService.exe", "FusionCEF.exe", "Fusion360Helper.exe"}+        for line in output.strip().split("\n"):+            line = line.strip()+            if not line:+                continue+            # CSV format: "name","pid","session","session#","mem"+            parts = line.split('","')+            if len(parts) >= 2:+                name = parts[0].strip('"')+                pid = parts[1].strip('"')+                if name in fusion_names:+                    result.append({"name": name, "pid": int(pid)})+    except Exception as e:+        print(f"  Error listing processes: {e}")+    return result+++def _find_fusion_windows() -> list[dict]:+    """Find all visible Fusion 360 windows.++    Matches windows whose title starts with 'Autodesk Fusion' (the actual Fusion app)+    rather than browser tabs that happen to mention it.+    """+    windows = []++    def enum_callback(hwnd, _):+        if IsWindowVisible(hwnd):+            length = GetWindowTextLengthW(hwnd)+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                GetWindowTextW(hwnd, buf, length + 1)+                title = buf.value+                # Fusion windows typically start with "Autodesk Fusion" or the design name+                # followed by "- Autodesk Fusion". We also check the process name.+                if "Autodesk Fusion" in title:+                    pid = ctypes.wintypes.DWORD()+                    GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+                    # Verify it's actually a Fusion process, not a browser+                    if _is_fusion_pid(pid.value):+                        windows.append({+                            "hwnd": hwnd,+                            "title": title,+                            "pid": pid.value,+                        })+        return True++    EnumWindows(WNDENUMPROC(enum_callback), 0)+    return windows+++def _is_fusion_pid(pid: int) -> bool:+    """Check if a PID belongs to a Fusion 360 process."""+    try:+        output = subprocess.check_output(+            ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],+            stderr=subprocess.DEVNULL,+            text=True,+            timeout=5,+        )+        fusion_names = {"Fusion360.exe", "FusionLauncher.exe", "Fusion.exe"}+        for name in fusion_names:+            if name in output:+                return True+    except Exception:+        pass+    return False+++def _close_fusion_window(hwnd) -> bool:+    """Send WM_CLOSE to a Fusion window for graceful shutdown."""+    return PostMessageW(hwnd, WM_CLOSE, 0, 0) != 0+++def _force_kill_pid(pid: int):+    """Force kill a process by PID."""+    try:+        subprocess.run(["taskkill", "/F", "/PID", str(pid)],+                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+    except Exception:+        pass+++def _check_port(port: int, timeout: float = 2.0) -> dict | None:+    """Check if an HTTP server is responding on a port."""+    try:+        req = urllib.request.Request(f"http://127.0.0.1:{port}/health", method="GET")+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            return json.loads(resp.read())+    except Exception:+        return None+++def cmd_status():+    """Show comprehensive Fusion 360 status."""+    print("=" * 60)+    print("FUSION 360 STATUS")+    print("=" * 60)++    # Detection+    info = detect_fusion()+    print(f"\n[Detection]")+    print(f"  Installed:     {info.get('installed', False)}")+    if info.get("installed"):+        print(f"  Exe path:      {info.get('exe_path')}")+        print(f"  Add-in files:  {'installed' if info.get('addin_installed') else 'NOT installed'}")+    else:+        print(f"  WARNING: Fusion 360 not found!")++    # Processes+    procs = _get_fusion_processes()+    print(f"\n[Processes] ({len(procs)} Fusion processes)")+    for p in procs:+        print(f"  PID {p['pid']:>8}  {p['name']}")++    # Windows+    windows = _find_fusion_windows()+    print(f"\n[Windows] ({len(windows)} Fusion windows)")+    for w in windows:+        title = w['title'].encode('ascii', 'replace').decode('ascii')+        print(f"  HWND {w['hwnd']}  PID {w['pid']}  \"{title}\"")++    # Bridge server+    print(f"\n[Bridge Server] (port {BRIDGE_PORT})")+    bridge = _check_port(BRIDGE_PORT)+    if bridge:+        print(f"  Status: RUNNING")+        addin_info = bridge.get("addin")+        if addin_info:+            print(f"  Add-in via bridge: CONNECTED")+        else:+            print(f"  Add-in via bridge: NOT CONNECTED")+    else:+        print(f"  Status: NOT RUNNING")++    # Direct add-in check+    print(f"\n[Add-In Server] (port {ADDIN_PORT})")+    addin = _check_port(ADDIN_PORT)+    if addin:+        print(f"  Status: RUNNING — {addin}")+    else:+        print(f"  Status: NOT RUNNING")++    # Summary+    running = len(procs) > 0+    has_window = len(windows) > 0+    addin_up = addin is not None++    print(f"\n[Summary]")+    print(f"  Fusion running:  {'YES' if running else 'NO'}")+    print(f"  UI visible:      {'YES' if has_window else 'NO'}")+    print(f"  Add-in active:   {'YES' if addin_up else 'NO'}")+    print(f"  Bridge running:  {'YES' if bridge else 'NO'}")+    print("=" * 60)++    return {"running": running, "window": has_window, "addin": addin_up, "bridge": bridge is not None}+++def cmd_start(wait_for_addin: bool = True, timeout: int = 120):+    """Launch Fusion 360 and optionally wait for the add-in."""+    info = detect_fusion()+    if not info.get("installed"):+        print("ERROR: Fusion 360 not found!")+        return False++    # Check if already running+    procs = _get_fusion_processes()+    if procs:+        print(f"Fusion 360 already running ({len(procs)} processes)")+        windows = _find_fusion_windows()+        if windows:+            print(f"  Window: \"{windows[0]['title']}\"")+    else:+        exe_path = info["exe_path"]+        print(f"Launching: {exe_path}")+        try:+            subprocess.Popen(+                [exe_path],+                stdout=subprocess.DEVNULL,+                stderr=subprocess.DEVNULL,+            )+        except Exception as e:+            print(f"ERROR: Failed to launch: {e}")+            return False++        # Wait for window+        print("Waiting for Fusion window...", end="", flush=True)+        for i in range(timeout):+            time.sleep(1)+            windows = _find_fusion_windows()+            if windows:+                print(f" found after {i+1}s")+                print(f"  Window: \"{windows[0]['title']}\"")+                break+            if i % 10 == 9:+                print(".", end="", flush=True)+        else:+            print(f" TIMEOUT after {timeout}s")+            return False++    if wait_for_addin:+        print("Waiting for add-in on port 8774...", end="", flush=True)+        for i in range(timeout):+            time.sleep(1)+            addin = _check_port(ADDIN_PORT, timeout=1.0)+            if addin:+                print(f" ready after {i+1}s")+                return True+            if i % 10 == 9:+                print(".", end="", flush=True)+        print(f" TIMEOUT — add-in not responding after {timeout}s")+        print("  You may need to enable it: UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run")+        return False++    return True+++def cmd_stop():+    """Graceful stop — send WM_CLOSE to Fusion windows."""+    windows = _find_fusion_windows()+    if not windows:+        print("No Fusion windows found.")+        procs = _get_fusion_processes()+        if procs:+            print(f"  But {len(procs)} Fusion processes are running. Use 'kill' to force-stop.")+        return++    for w in windows:+        print(f"Closing: \"{w['title']}\" (PID {w['pid']})")+        _close_fusion_window(w["hwnd"])++    # Wait for processes to exit+    print("Waiting for shutdown...", end="", flush=True)+    for i in range(30):+        time.sleep(1)+        procs = _get_fusion_processes()+        if not procs:+            print(f" done after {i+1}s")+            return+        if i % 5 == 4:+            print(".", end="", flush=True)++    remaining = _get_fusion_processes()+    print(f"\n  {len(remaining)} processes still running. Use 'kill' to force-stop.")+++def cmd_kill():+    """Force kill ALL Fusion processes."""+    procs = _get_fusion_processes()+    if not procs:+        print("No Fusion processes running.")+        return++    print(f"Force-killing {len(procs)} Fusion processes...")+    pids = set()+    for p in procs:+        pids.add(p["pid"])+        print(f"  Killing PID {p['pid']} ({p['name']})")++    for pid in pids:+        _force_kill_pid(pid)++    time.sleep(1)+    remaining = _get_fusion_processes()+    if remaining:+        print(f"  WARNING: {len(remaining)} processes still running")+    else:+        print("  All Fusion processes stopped.")+++def cmd_restart(wait_for_addin: bool = True):+    """Kill Fusion and restart it."""+    print("=== Stopping Fusion 360 ===")+    cmd_kill()+    time.sleep(2)+    print("\n=== Starting Fusion 360 ===")+    return cmd_start(wait_for_addin=wait_for_addin)+++def cmd_addin():+    """Check add-in status and attempt to reach it."""+    addin = _check_port(ADDIN_PORT)+    if addin:+        print(f"Add-in is running: {json.dumps(addin)}")+        return True+    else:+        print("Add-in is NOT responding on port 8774.")+        procs = _get_fusion_processes()+        if not procs:+            print("  Fusion is not running. Use 'start' to launch it.")+        else:+            print(f"  Fusion is running ({len(procs)} processes) but add-in is not active.")+            print("  Try: UTILITIES tab > ADD-INS > AdomBridge > check 'Run on Startup' > click 'Run'")+        return False+++def main():+    if len(sys.argv) < 2:+        print(__doc__)+        sys.exit(0)++    cmd = sys.argv[1].lower()++    if cmd == "status":+        cmd_status()+    elif cmd == "start":+        success = cmd_start()+        sys.exit(0 if success else 1)+    elif cmd == "stop":+        cmd_stop()+    elif cmd == "kill":+        cmd_kill()+    elif cmd == "restart":+        success = cmd_restart()+        sys.exit(0 if success else 1)+    elif cmd == "addin":+        success = cmd_addin()+        sys.exit(0 if success else 1)+    else:+        print(f"Unknown command: {cmd}")+        print(__doc__)+        sys.exit(1)+++if __name__ == "__main__":+    main()
handlers/__init__.pyadded

  
handlers/close_fusion.pyadded+335
@@ -0,0 +1,335 @@+"""Handler for close_fusion command.++Closes all open documents cleanly via the add-in (avoiding recovered documents+dialog on next launch), then sends WM_CLOSE to all Fusion 360 windows, with+optional force-kill.++IMPORTANT: Always close documents via the add-in BEFORE killing Fusion.+taskkill //f causes unsaved document recovery on next launch, which shows a+blocking modal dialog that prevents automation.+"""++import ctypes+import ctypes.wintypes+import json+import subprocess+import time+import urllib.request+import urllib.error++WM_CLOSE = 0x0010++# Guard windll so the module imports on non-Windows hosts (the bridge must boot+# + serve /status anywhere); these Win32 paths are only reached on Windows.+user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None++_callbacks = []++# Fusion process names to kill when force-stopping+FUSION_PROCESS_NAMES = [+    "Fusion360.exe", "FusionLauncher.exe", "Fusion.exe",+    "FusionService.exe", "FusionCEF.exe", "Fusion360Helper.exe",+]+++def _find_windows_by_title(substring: str) -> list:+    """Find all visible windows whose title contains the given substring."""+    results = []+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            length = user32.GetWindowTextLengthW(hwnd)+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                if substring in buf.value:+                    results.append((hwnd, buf.value))+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return results+++def _close_and_wait(hwnd: int, title: str, timeout: float = 15.0) -> bool:+    """Send WM_CLOSE and wait for the window to disappear.++    Fusion 360 may take longer to close than typical apps, so default timeout is 15s.+    """+    user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)++    deadline = time.monotonic() + timeout+    while time.monotonic() < deadline:+        time.sleep(0.3)+        if not user32.IsWindow(hwnd) or not user32.IsWindowVisible(hwnd):+            return True+    return False+++def _force_kill_fusion() -> int:+    """Force kill all Fusion processes. Returns number of processes killed."""+    killed = 0+    for name in FUSION_PROCESS_NAMES:+        try:+            result = subprocess.run(+                ["taskkill", "/F", "/IM", name],+                stdout=subprocess.DEVNULL,+                stderr=subprocess.DEVNULL,+            )+            if result.returncode == 0:+                killed += 1+        except Exception:+            pass+    return killed+++def _has_fusion_process() -> bool:+    """Check if any Fusion process is still running."""+    try:+        output = subprocess.check_output(+            ["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],+            stderr=subprocess.DEVNULL,+            text=True,+            timeout=5,+        )+        return "Fusion360.exe" in output+    except Exception:+        return False+++ADDIN_PORT = 8774+++def _close_all_documents_via_addin() -> dict:+    """Ask the add-in to close all open documents without saving.++    This prevents the 'Recovered Documents' dialog on next launch.+    Returns {"closed": [...], "errors": [...], "count": N}.+    """+    result = {"closed": [], "errors": [], "count": 0}++    # First, get list of open documents+    try:+        body = json.dumps({"command": "document_info", "args": {}}).encode("utf-8")+        req = urllib.request.Request(+            f"http://127.0.0.1:{ADDIN_PORT}/command",+            data=body,+            headers={"Content-Type": "application/json"},+            method="POST",+        )+        with urllib.request.urlopen(req, timeout=5) as resp:+            resp_data = json.loads(resp.read())+    except Exception:+        return result  # Add-in not reachable, skip clean close++    if not resp_data.get("success"):+        return result++    docs = resp_data.get("data", {}).get("openDocuments", [])+    result["count"] = len(docs)++    if not docs:+        return result++    # Close each document (close active last to avoid switching issues)+    # Reverse order so we close non-active docs first+    for doc in reversed(docs):+        doc_name = doc.get("name", "")+        if not doc_name:+            continue+        try:+            body = json.dumps({+                "command": "close_document",+                "args": {"name": doc_name, "save": False},+            }).encode("utf-8")+            req = urllib.request.Request(+                f"http://127.0.0.1:{ADDIN_PORT}/command",+                data=body,+                headers={"Content-Type": "application/json"},+                method="POST",+            )+            with urllib.request.urlopen(req, timeout=10) as resp:+                close_resp = json.loads(resp.read())+            if close_resp.get("success"):+                result["closed"].append(doc_name)+            else:+                result["errors"].append(f"{doc_name}: {close_resp.get('error', '?')}")+        except Exception as e:+            result["errors"].append(f"{doc_name}: {e}")++    return result+++def handle_close_fusion(fusion_info: dict, args: dict) -> dict:+    """Close all Fusion 360 windows, with optional force-kill.++    Always attempts to close documents cleanly via the add-in first,+    to prevent the 'Recovered Documents' dialog on next launch.++    Args:+        fusion_info: Fusion 360 detection info dict (unused but required by interface).+        args: Dict with:+            - force: If true, force-kill all Fusion processes (default: false).+            - skipCleanClose: If true, skip document close via add-in (default: false).+    """+    force = args.get("force", False)+    skip_clean = args.get("skipCleanClose", False)++    # Always try to close documents cleanly first (unless explicitly skipped)+    close_report = {"closed": [], "errors": [], "count": 0}+    if not skip_clean:+        close_report = _close_all_documents_via_addin()+        if close_report["closed"]:+            time.sleep(0.5)  # Brief pause after closing docs++    if force:+        # Force kill all Fusion processes immediately+        killed = _force_kill_fusion()+        time.sleep(1)+        if _has_fusion_process():+            return {+                "success": False,+                "error": "Some Fusion processes survived force-kill.",+                "_hint": "Report to the user and ask them to manually kill remaining Fusion processes via Task Manager, then retry.",+            }+        return {+            "success": True,+            "output": f"Force-killed Fusion 360 ({killed} process group{'s' if killed != 1 else ''}). "+                      f"Closed {len(close_report['closed'])} document(s) cleanly first.",+            "data": {"documentsClosed": close_report["closed"]},+        }++    # Graceful close: send WM_CLOSE to windows+    windows = _find_windows_by_title("Autodesk Fusion")++    if not windows:+        # No windows but maybe processes are hanging+        if _has_fusion_process():+            return {+                "success": True,+                "output": "No Fusion windows found but processes are running. Use force=true to kill them.",+            }+        return {"success": True, "output": "Fusion 360 is not running."}++    closed = []+    failed = []+    for hwnd, title in windows:+        if _close_and_wait(hwnd, title):+            closed.append(title)+        else:+            failed.append(title)++    if failed:+        # Try force-killing since graceful close failed+        _force_kill_fusion()+        time.sleep(1)+        if not _has_fusion_process():+            return {+                "success": True,+                "output": f"Fusion 360 force-killed after graceful close failed.",+            }+        return {+            "success": False,+            "error": f"Failed to close: {', '.join(failed)}",+            "output": f"Closed: {', '.join(closed)}" if closed else "",+            "_hint": "Call fusion_close with {\"force\": true} to force-kill stuck windows, then retry.",+        }++    # Wait for lingering processes+    for _ in range(10):+        if not _has_fusion_process():+            break+        time.sleep(0.5)++    return {+        "success": True,+        "output": f"Fusion 360 closed ({len(closed)} window{'s' if len(closed) != 1 else ''}).",+    }+++def handle_fusion_stop(fusion_info: dict, args: dict) -> dict:+    """Gracefully STOP Fusion 360 - the clean opposite of fusion_start.++    Closes all docs cleanly via the add-in (avoids the 'Recovered Documents' dialog),+    then WM_CLOSE every Fusion window and waits. Does NOT force-kill: if a window will+    not close (usually a modal dialog blocking WM_CLOSE), it returns failure and tells+    you to use fusion_kill. No surprise escalation - that's the whole point.++    Args: skipCleanClose (bool, default false) - skip the add-in document close.+    """+    skip_clean = args.get("skipCleanClose", False)+    close_report = {"closed": [], "errors": [], "count": 0}+    if not skip_clean:+        close_report = _close_all_documents_via_addin()+        if close_report["closed"]:+            time.sleep(0.5)++    windows = _find_windows_by_title("Autodesk Fusion")+    if not windows:+        if _has_fusion_process():+            return {+                "success": False,+                "error": "No Fusion windows found, but Fusion processes are still running.",+                "_hint": "A graceful stop cannot reach them - use fusion_kill to force-kill.",+            }+        return {"success": True, "output": "Fusion 360 is not running."}++    closed, failed = [], []+    for hwnd, title in windows:+        (closed if _close_and_wait(hwnd, title) else failed).append(title)++    if failed:+        return {+            "success": False,+            "error": f"Graceful stop failed for: {', '.join(failed)}",+            "output": f"Closed: {', '.join(closed)}" if closed else "",+            "_hint": "A window would not close gracefully (a modal dialog often blocks WM_CLOSE). "+                     "Use fusion_kill to force-kill, then retry.",+        }++    for _ in range(10):+        if not _has_fusion_process():+            break+        time.sleep(0.5)+    return {+        "success": True,+        "output": f"Fusion 360 stopped gracefully ({len(closed)} window{'s' if len(closed) != 1 else ''}); "+                  f"{len(close_report['closed'])} document(s) closed cleanly first.",+        "data": {"documentsClosed": close_report["closed"]},+    }+++def handle_fusion_kill(fusion_info: dict, args: dict) -> dict:+    """Force-KILL Fusion 360 (taskkill /F) - the 'desperate' path for when fusion_stop+    can't close it (a stuck modal, a wedged process).++    Still closes docs cleanly via the add-in FIRST when reachable, to avoid the+    'Recovered Documents' dialog on next launch.++    Args: skipCleanClose (bool, default false) - skip the add-in document close.+    """+    skip_clean = args.get("skipCleanClose", False)+    close_report = {"closed": [], "errors": [], "count": 0}+    if not skip_clean:+        close_report = _close_all_documents_via_addin()+        if close_report["closed"]:+            time.sleep(0.5)+    killed = _force_kill_fusion()+    time.sleep(1)+    if _has_fusion_process():+        return {+            "success": False,+            "error": "Some Fusion processes survived force-kill.",+            "_hint": "Ask the user to kill remaining Fusion processes via Task Manager, then retry.",+        }+    return {+        "success": True,+        "output": f"Fusion 360 force-killed ({killed} process group{'s' if killed != 1 else ''}); "+                  f"{len(close_report['closed'])} document(s) closed cleanly first.",+        "data": {"documentsClosed": close_report["closed"]},+    }
handlers/dialog_classify.pyadded+97
@@ -0,0 +1,97 @@+"""Classify Fusion 360 blocking dialogs by title, with resolution hints.++Why this exists: several Fusion modals (the multi-linked "Select Electronics+Design File" picker, the "Fusion needs to update" nag, Document Recovery, "Save+changes?", "What do you want to design?") block Fusion's main thread. When that+happens during an add-in command, the add-in's HTTP server can't respond and the+bridge reports a misleading "add-in not responding / may have crashed" — which+sends callers into a pointless restart loop.++Qt dialog *titles* are enumerable over Win32 even while the add-in is blocked+(unlike the dialogs' CEF/Qt child controls, which are not). So we can cheaply+identify WHICH modal is up and return an actionable resolution instead of a fake+crash. This is bridge-wide: it helps every command that can be blocked by a+modal, not just exports.+"""++from handlers.fusion_ui import get_fusion_window_info+++# (category, resolution-hint) keyed by lowercase title substrings, checked in order.+_RULES = [+    (("needs to update", "update is available", "software update", "new update"),+     "update_nag",+     "Fusion is prompting to update itself. Do NOT auto-confirm (it can start an "+     "update mid-automation). Dismiss it on the desktop or let Fusion update, then "+     "retry. A pending update is also a common cause of AdomBridge add-in crashes "+     "(add-in/host version drift), so clearing it often fixes those too."),++    (("select electronics design", "multiple electronics design", "linked to multiple"),+     "linked_design_picker",+     "This file links to multiple Electronics designs. The chooser is a CEF/web "+     "modal whose list is NOT keyboard- or Win32-navigable (SendInput reaches only "+     "the native Cancel/OK), so it can't be resolved headlessly today — select the "+     "design on the desktop. (Headless fix tracked: have the add-in open the specific "+     "linked design via the Fusion API, avoiding the picker entirely.)"),++    (("recovery", "recover unsaved", "document recovery"),+     "recovery",+     "Document Recovery prompt. Call fusion_dismiss_recovery, or fusion_relocate_recovery "+     "BEFORE fusion_start to prevent it."),++    (("save changes", "save document", "do you want to save", "unsaved changes"),+     "save_changes",+     "Unsaved-changes prompt. Use fusion_close_document (closes without the save modal), "+     "or fusion_send_key {\"key\":\"tab\"} then {\"key\":\"enter\"}."),++    (("what do you want to design", "what to design"),+     "what_to_design",+     "Fusion's start picker. fusion_send_key {\"key\":\"escape\"} to dismiss."),+]+++def _category_for_title(title: str):+    t = (title or "").strip().lower()+    if not t:+        return None+    for substrings, category, resolution in _RULES:+        if any(s in t for s in substrings):+            return category, resolution+    # A bare "Fusion360"-titled modal appearing during an open is, in practice,+    # almost always the multi-linked "Select Electronics Design File" chooser+    # (its window title is just "Fusion360", not descriptive). Flag it as a+    # likely picker so callers can try the headless resolver before giving up.+    if t in ("fusion360", "autodesk fusion 360", "fusion 360"):+        return ("possible_linked_design_picker",+                "Generic Fusion modal — during a file open this is most often the "+                "'Select Electronics Design File' chooser, a CEF modal that can't be "+                "resolved headlessly (keyboard/Win32 don't navigate its list). "+                "fusion_screenshot_fusion to confirm, then select the design on the "+                "desktop.")+    return "unknown", "Unrecognized modal — fusion_screenshot_fusion to inspect, then dismiss."+++def classify_blocking_dialogs() -> list:+    """Return classified blocking dialogs currently open in Fusion.++    Each entry: {hwnd, title, category, resolution}. Empty list if none.+    Never raises — best-effort enumeration.+    """+    out = []+    try:+        info = get_fusion_window_info() or {}+    except Exception:+        return out+    for dlg in info.get("dialogs", []) or []:+        title = dlg.get("title", "")+        classified = _category_for_title(title)+        if not classified:+            continue+        category, resolution = classified+        out.append({+            "hwnd": dlg.get("hwnd"),+            "title": title,+            "category": category,+            "resolution": resolution,+        })+    return out
handlers/dismiss_recovery.pyadded+332
@@ -0,0 +1,332 @@+"""Dismiss Fusion 360 recovery dialogs via Win32 API.++After a crash or force-kill, Fusion shows two types of recovery dialogs:++1. "Recovered Documents" list dialog (Qt window titled "Fusion360"):+   Shows all recovery files with Open/Delete/Close buttons.+   Dismissed via WM_CLOSE.++2. "Open recovery document instead?" prompt (Qt window with that title):+   Appears when opening a file that has a recovery document.+   Has Yes/No buttons. We click No (Tab + Enter) to skip recovery+   and open the cloud version instead.++Both are Qt-rendered dialogs with no Win32 child controls, so we use+keyboard navigation (Tab/Enter) instead of button clicking.++Recovery files are NOT deleted — they are moved to ~/.adom/recovery/fusion/+so the user can manually recover them if needed. Each batch is timestamped.+"""++import ctypes+import ctypes.wintypes+import glob+import os+import shutil+import time++# Guard windll so the module imports on non-Windows hosts (the bridge must boot+# + serve /status anywhere); these Win32 paths are only reached on Windows.+user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None++WM_CLOSE = 0x0010+WM_COMMAND = 0x0111+BM_CLICK = 0x00F5++# Keep callback references alive to prevent GC+_callbacks = []+++def _get_fusion_crash_recovery_dirs() -> list:+    """Find all CrashRecovery directories under Fusion's local app data.++    Fusion stores recovery files at:+      %LOCALAPPDATA%/Autodesk/Autodesk Fusion 360/<USER_ID>/CrashRecovery/++    There may be multiple user ID folders. Returns list of existing+    CrashRecovery dirs that contain at least one file.+    """+    base = os.path.join(+        os.environ.get("LOCALAPPDATA", ""),+        "Autodesk", "Autodesk Fusion 360"+    )+    if not os.path.isdir(base):+        return []++    dirs = []+    for entry in os.listdir(base):+        cr_dir = os.path.join(base, entry, "CrashRecovery")+        if os.path.isdir(cr_dir) and os.listdir(cr_dir):+            dirs.append(cr_dir)+    return dirs+++def relocate_recovery_files() -> dict:+    """Move Fusion crash recovery files to ~/.adom/recovery/fusion/.++    Instead of deleting recovery files (which removes a useful safety net),+    we relocate them to Adom's own recovery folder. Each batch gets a+    timestamped subfolder so the user can find and restore them if needed.++    Returns dict with:+      - moved: number of files/folders moved+      - dest: destination directory (or None if nothing moved)+      - sources: list of source CrashRecovery dirs that were processed+    """+    cr_dirs = _get_fusion_crash_recovery_dirs()+    if not cr_dirs:+        return {"moved": 0, "dest": None, "sources": []}++    # Create timestamped destination+    timestamp = time.strftime("%Y%m%d_%H%M%S")+    adom_home = os.path.join(os.path.expanduser("~"), ".adom")+    dest_base = os.path.join(adom_home, "recovery", "fusion", timestamp)+    os.makedirs(dest_base, exist_ok=True)++    moved = 0+    sources = []+    for cr_dir in cr_dirs:+        sources.append(cr_dir)+        for item in os.listdir(cr_dir):+            src = os.path.join(cr_dir, item)+            dst = os.path.join(dest_base, item)+            # Handle name collisions by appending a suffix+            if os.path.exists(dst):+                base_name, ext = os.path.splitext(item)+                dst = os.path.join(dest_base, f"{base_name}_{moved}{ext}")+            try:+                shutil.move(src, dst)+                moved += 1+            except Exception:+                # If move fails (file locked), try copy-then-delete+                try:+                    if os.path.isdir(src):+                        shutil.copytree(src, dst)+                    else:+                        shutil.copy2(src, dst)+                    shutil.rmtree(src) if os.path.isdir(src) else os.remove(src)+                    moved += 1+                except Exception:+                    pass  # File may be locked by Fusion — skip it++    # Clean up empty CrashRecovery dirs+    for cr_dir in cr_dirs:+        try:+            if os.path.isdir(cr_dir) and not os.listdir(cr_dir):+                os.rmdir(cr_dir)+        except Exception:+            pass++    # If nothing was actually moved, remove the empty timestamp dir+    if moved == 0:+        try:+            os.rmdir(dest_base)+        except Exception:+            pass+        return {"moved": 0, "dest": None, "sources": sources}++    return {"moved": moved, "dest": dest_base, "sources": sources}+++def _find_window_by_title(title_substring: str) -> int:+    """Find a visible window whose title contains the given substring."""+    result = [0]+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            length = user32.GetWindowTextLengthW(hwnd)+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                if title_substring in buf.value:+                    result[0] = hwnd+                    return False  # Stop enumeration+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return result[0]+++def _find_fusion_dialog_windows() -> list:+    """Find small Qt dialog windows that belong to Fusion 360.++    Fusion's recovery dialog is a Qt window with class 'Qt655QWindowIcon'+    and title 'Fusion360' (NOT 'Recovered Documents'). It's smaller than+    the main Fusion window. We detect it by finding Qt windows titled+    'Fusion360' that are NOT the main Fusion window (which has+    'Autodesk Fusion' in its title).+    """+    results = []+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            # Get class name+            cls_buf = ctypes.create_unicode_buffer(256)+            user32.GetClassNameW(hwnd, cls_buf, 256)+            class_name = cls_buf.value++            # Get title+            length = user32.GetWindowTextLengthW(hwnd)+            title = ""+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                title = buf.value++            # Look for Qt dialog windows titled "Fusion360" (not the main window)+            if "Qt" in class_name and title == "Fusion360":+                results.append(hwnd)+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return results+++def _find_child_button(parent_hwnd: int, button_text: str) -> int:+    """Find a child button control with the given text."""+    result = [0]+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        # Get the control text+        length = user32.GetWindowTextLengthW(hwnd)+        if length > 0:+            buf = ctypes.create_unicode_buffer(length + 1)+            user32.GetWindowTextW(hwnd, buf, length + 1)+            if buf.value.strip().lower() == button_text.lower():+                result[0] = hwnd+                return False+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumChildWindows(parent_hwnd, cb, 0)+    _callbacks.remove(cb)+    return result[0]+++VK_ESCAPE = 0x1B+VK_TAB = 0x09+VK_RETURN = 0x0D+WM_KEYDOWN = 0x0100+WM_KEYUP = 0x0101+++def _send_key(hwnd: int, vk: int, pause: float = 0.05):+    """Send a key press (down + up) to a window."""+    user32.PostMessageW(hwnd, WM_KEYDOWN, vk, 0)+    time.sleep(pause)+    user32.PostMessageW(hwnd, WM_KEYUP, vk, 0)+++def _dismiss_recovery_prompt(hwnd: int) -> bool:+    """Dismiss an 'Open recovery document instead?' Qt dialog by clicking No.++    The dialog has Yes (focused by default) and No buttons.+    Tab moves focus to No, Enter clicks it. This skips the recovery+    and opens the cloud version instead.+    """+    user32.SetForegroundWindow(hwnd)+    time.sleep(0.2)+    _send_key(hwnd, VK_TAB)+    time.sleep(0.2)+    _send_key(hwnd, VK_RETURN)+    return True+++def dismiss_recovery_dialog(max_wait: float = 5.0) -> bool:+    """Find and dismiss all Fusion 360 recovery dialogs.++    Before dismissing, relocates recovery files from Fusion's CrashRecovery+    directory to ~/.adom/recovery/fusion/<timestamp>/ so the user can+    manually recover them if needed.++    Handles two dialog types:+    1. "Open recovery document instead?" — Qt dialog, dismissed via Tab+Enter (No)+    2. "Fusion360" titled Qt dialog — the Recovered Documents list, dismissed via WM_CLOSE+    3. Fallback: Send Escape to Fusion main window++    Loops to handle multiple recovery prompts (one per recovered file).+    Returns True if any dismiss attempt was made.+    """+    # Relocate recovery files BEFORE dismissing dialogs+    # This preserves them in ~/.adom/recovery/fusion/ for the user+    try:+        reloc = relocate_recovery_files()+        if reloc["moved"] > 0:+            import logging+            logging.getLogger(__name__).info(+                "Relocated %d recovery file(s) to %s",+                reloc["moved"], reloc["dest"]+            )+    except Exception:+        pass  # Don't let relocation failure block dialog dismissal++    deadline = time.monotonic() + max_wait+    dismissed_any = False++    while time.monotonic() < deadline:+        # Priority 1: "Open recovery document instead?" prompt+        prompt_hwnd = _find_window_by_title("Open recovery document instead?")+        if prompt_hwnd:+            _dismiss_recovery_prompt(prompt_hwnd)+            dismissed_any = True+            time.sleep(0.5)+            continue  # Check for more prompts++        # Priority 2: "Fusion360" titled Qt dialogs (Recovered Documents list)+        dialog_hwnds = _find_fusion_dialog_windows()+        if dialog_hwnds:+            for hwnd in dialog_hwnds:+                user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+            dismissed_any = True+            time.sleep(0.5)+            continue  # Check for more++        # Priority 3: Look for a window explicitly titled "Recovered Documents"+        hwnd = _find_window_by_title("Recovered Documents")+        if hwnd:+            close_btn = _find_child_button(hwnd, "Close")+            if close_btn:+                user32.PostMessageW(close_btn, BM_CLICK, 0, 0)+            else:+                user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+            dismissed_any = True+            time.sleep(0.5)+            continue++        # If we already dismissed something, check one more time+        if dismissed_any:+            time.sleep(0.5)+            # One final check+            if not _find_window_by_title("Open recovery document instead?") \+               and not _find_fusion_dialog_windows() \+               and not _find_window_by_title("Recovered Documents"):+                break+            continue++        # Priority 4: Send Escape to the main Fusion window (last resort)+        fusion_hwnd = _find_window_by_title("Autodesk Fusion")+        if fusion_hwnd:+            _send_key(fusion_hwnd, VK_ESCAPE)+            dismissed_any = True+            time.sleep(1.0)+            continue++        time.sleep(0.5)++    return dismissed_any
handlers/fusion_ui.pyadded+706
@@ -0,0 +1,706 @@+"""Win32-based tools for interacting with Fusion 360's UI.++Provides screenshot, click, and keyboard input functions that work with+CEF (Chromium Embedded Framework) modal dialogs inside Fusion 360.+These dialogs can't be accessed via standard Win32 child window enumeration,+so we use screen-DC capture (BitBlt) instead of PrintWindow, and SendInput+instead of PostMessage for input.+"""++import ctypes+import ctypes.wintypes+import os+import struct+import time++# Guard windll so the module imports on non-Windows hosts (the bridge must boot+# + serve /status anywhere); these Win32 paths are only reached on Windows.+_have_windll = hasattr(ctypes, "windll")+user32 = ctypes.windll.user32 if _have_windll else None+gdi32 = ctypes.windll.gdi32 if _have_windll else None+kernel32 = ctypes.windll.kernel32 if _have_windll else None++# Enable DPI awareness so GetWindowRect returns physical pixel coordinates+# (not logical/scaled). Without this, screenshots are cropped on HiDPI displays.+try:+    ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PROCESS_PER_MONITOR_DPI_AWARE+except Exception:+    try:+        user32.SetProcessDPIAware()  # Fallback for older Windows+    except Exception:+        pass++# --- Constants ---++SRCCOPY = 0x00CC0020+DIB_RGB_COLORS = 0+BI_RGB = 0+SM_CXVIRTUALSCREEN = 78+SM_CYVIRTUALSCREEN = 79++INPUT_MOUSE = 0+INPUT_KEYBOARD = 1+MOUSEEVENTF_ABSOLUTE = 0x8000+MOUSEEVENTF_MOVE = 0x0001+MOUSEEVENTF_LEFTDOWN = 0x0002+MOUSEEVENTF_LEFTUP = 0x0004+KEYEVENTF_KEYUP = 0x0002++# Virtual key codes+VK_MAP = {+    "enter": 0x0D, "return": 0x0D,+    "escape": 0x1B, "esc": 0x1B,+    "tab": 0x09,+    "space": 0x20,+    "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,+    "backspace": 0x08, "delete": 0x2E,+    "home": 0x24, "end": 0x23,+    "pageup": 0x21, "pagedown": 0x22,+    "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,+    "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,+    "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,+}++SCREENSHOT_DIR = "C:/tmp/conduit-screenshots"++# Keep callback references alive to prevent GC+_callbacks = []+++# --- SendInput structures ---++class MOUSEINPUT(ctypes.Structure):+    _fields_ = [+        ("dx", ctypes.wintypes.LONG),+        ("dy", ctypes.wintypes.LONG),+        ("mouseData", ctypes.wintypes.DWORD),+        ("dwFlags", ctypes.wintypes.DWORD),+        ("time", ctypes.wintypes.DWORD),+        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+    ]+++class KEYBDINPUT(ctypes.Structure):+    _fields_ = [+        ("wVk", ctypes.wintypes.WORD),+        ("wScan", ctypes.wintypes.WORD),+        ("dwFlags", ctypes.wintypes.DWORD),+        ("time", ctypes.wintypes.DWORD),+        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+    ]+++class _INPUT_UNION(ctypes.Union):+    _fields_ = [+        ("mi", MOUSEINPUT),+        ("ki", KEYBDINPUT),+    ]+++class INPUT(ctypes.Structure):+    _fields_ = [+        ("type", ctypes.wintypes.DWORD),+        ("union", _INPUT_UNION),+    ]+++def _send_input(*inputs):+    """Send one or more INPUT structures via SendInput."""+    n = len(inputs)+    arr = (INPUT * n)(*inputs)+    user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))+++# --- Window finding ---++def _find_fusion_hwnd() -> int:+    """Find Fusion 360's main window by title containing 'Autodesk Fusion'."""+    result = [0]+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            length = user32.GetWindowTextLengthW(hwnd)+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                if "Autodesk Fusion" in buf.value:+                    result[0] = hwnd+                    return False+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return result[0]+++def _find_qt_dialog_windows() -> list:+    """Find visible Qt windows that may be Fusion 360 dialogs.++    Returns list of dicts with hwnd, title, rect, className.+    Excludes the main Fusion window (which has 'Autodesk Fusion' in title).+    """+    results = []+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            cls_buf = ctypes.create_unicode_buffer(256)+            user32.GetClassNameW(hwnd, cls_buf, 256)+            class_name = cls_buf.value++            length = user32.GetWindowTextLengthW(hwnd)+            title = ""+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                title = buf.value++            # Qt windows belonging to Fusion (not the main window)+            if "Qt" in class_name and "Autodesk Fusion" not in title:+                # Some Fusion dialogs (including the "What do you want to+                # design?" startup picker) use Qt Tool window classes like+                # "Qt655QWindowToolSaveBits".  We used to skip ALL Tool+                # windows (they're usually docked panels — Browser, Timeline).+                # But that caused the startup picker to be invisible to+                # dialog detection. Now we only skip SMALL Tool windows+                # (docked panels are narrow sidebar panels, typically+                # <400px wide).  Large Tool windows (>400px wide AND >300px+                # tall) are kept — they're blocking dialogs like the picker.+                if "Tool" in class_name:+                    rect = ctypes.wintypes.RECT()+                    user32.GetWindowRect(hwnd, ctypes.byref(rect))+                    w = rect.right - rect.left+                    h = rect.bottom - rect.top+                    if w < 400 or h < 300:+                        return True  # small tool panel, skip++                rect = ctypes.wintypes.RECT()+                user32.GetWindowRect(hwnd, ctypes.byref(rect))+                results.append({+                    "hwnd": hwnd,+                    "title": title,+                    "className": class_name,+                    "rect": {+                        "left": rect.left, "top": rect.top,+                        "right": rect.right, "bottom": rect.bottom,+                        "width": rect.right - rect.left,+                        "height": rect.bottom - rect.top,+                    },+                })+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return results+++def _get_window_rect(hwnd: int) -> tuple:+    """Get window rect as (left, top, right, bottom)."""+    rect = ctypes.wintypes.RECT()+    user32.GetWindowRect(hwnd, ctypes.byref(rect))+    return (rect.left, rect.top, rect.right, rect.bottom)+++# --- Screenshot ---++def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):+    """Write a BMP file from raw BGR pixel data (bottom-up row order).++    pixel_data must be width*height*4 bytes (32-bit BGRA from GetDIBits).+    We write a 24-bit BMP (strip alpha) for broad compatibility.+    """+    row_size_24 = (width * 3 + 3) & ~3  # rows padded to 4 bytes+    pixel_size_24 = row_size_24 * height+    file_size = 14 + 40 + pixel_size_24  # BMP header + DIB header + pixels++    with open(path, "wb") as f:+        # BMP file header (14 bytes)+        f.write(b"BM")+        f.write(struct.pack("<I", file_size))+        f.write(struct.pack("<HH", 0, 0))  # reserved+        f.write(struct.pack("<I", 14 + 40))  # offset to pixel data++        # DIB header (BITMAPINFOHEADER, 40 bytes)+        f.write(struct.pack("<I", 40))  # header size+        f.write(struct.pack("<i", width))+        f.write(struct.pack("<i", height))  # positive = bottom-up+        f.write(struct.pack("<HH", 1, 24))  # planes, bpp+        f.write(struct.pack("<I", BI_RGB))  # compression+        f.write(struct.pack("<I", pixel_size_24))+        f.write(struct.pack("<ii", 2835, 2835))  # pixels/meter (~72 DPI)+        f.write(struct.pack("<II", 0, 0))  # colors++        # Write pixel rows (convert 32-bit BGRA to 24-bit BGR, bottom-up)+        src_row_size = width * 4+        for y in range(height):+            row_start = y * src_row_size+            row_24 = bytearray()+            for x in range(width):+                px = row_start + x * 4+                row_24 += pixel_data[px:px + 3]  # BGR (skip alpha)+            # Pad row to 4-byte boundary+            padding = row_size_24 - len(row_24)+            if padding > 0:+                row_24 += b"\x00" * padding+            f.write(bytes(row_24))+++PW_RENDERFULLCONTENT = 0x00000002+++def screenshot_hwnd(hwnd: int, label: str = "") -> dict:+    """Capture any window by HWND. Returns {success, savedTo, sizeKB}."""+    left, top, right, bottom = _get_window_rect(hwnd)+    width = right - left+    height = bottom - top++    if width <= 0 or height <= 0:+        return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}++    hdc_screen = user32.GetDC(hwnd)+    hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+    hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+    old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+    user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)++    class BITMAPINFOHEADER(ctypes.Structure):+        _fields_ = [+            ("biSize", ctypes.wintypes.DWORD),+            ("biWidth", ctypes.wintypes.LONG),+            ("biHeight", ctypes.wintypes.LONG),+            ("biPlanes", ctypes.wintypes.WORD),+            ("biBitCount", ctypes.wintypes.WORD),+            ("biCompression", ctypes.wintypes.DWORD),+            ("biSizeImage", ctypes.wintypes.DWORD),+            ("biXPelsPerMeter", ctypes.wintypes.LONG),+            ("biYPelsPerMeter", ctypes.wintypes.LONG),+            ("biClrUsed", ctypes.wintypes.DWORD),+            ("biClrImportant", ctypes.wintypes.DWORD),+        ]++    bmi = BITMAPINFOHEADER()+    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)+    bmi.biWidth = width+    bmi.biHeight = height+    bmi.biPlanes = 1+    bmi.biBitCount = 32+    bmi.biCompression = BI_RGB++    buf_size = width * height * 4+    pixel_buf = ctypes.create_string_buffer(buf_size)+    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)++    gdi32.SelectObject(hdc_mem, old_bmp)+    gdi32.DeleteObject(hbmp)+    gdi32.DeleteDC(hdc_mem)+    user32.ReleaseDC(hwnd, hdc_screen)++    os.makedirs(SCREENSHOT_DIR, exist_ok=True)+    timestamp = int(time.time() * 1000)+    suffix = f"-{label}" if label else ""++    try:+        from PIL import Image+        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")+        img = img.transpose(Image.FLIP_TOP_BOTTOM)+        MAX_DIM = 1568+        if max(width, height) > MAX_DIM:+            ratio = MAX_DIM / max(width, height)+            img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)+        try:+            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.webp")+            img.save(path, "WEBP", lossless=True, quality=100)+        except Exception:+            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")+            img.save(path, "PNG", optimize=True)+    except ImportError:+        path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.bmp")+        _write_bmp(path, width, height, pixel_buf.raw)++    size_kb = os.path.getsize(path) / 1024+    return {+        "success": True,+        "savedTo": path.replace("\\", "/"),+        "sizeKB": round(size_kb, 1),+    }+++def screenshot_fusion_window(use_bitblt: bool = False) -> dict:+    """Capture the Fusion 360 window.++    By default uses PrintWindow with PW_RENDERFULLCONTENT (works without+    foreground, captures Qt content but NOT CEF modal overlays).++    If use_bitblt=True, brings window to foreground and uses BitBlt from+    screen DC (captures CEF overlays but requires foreground).++    Saves PNG to C:/tmp/conduit-screenshots/ and returns the path.+    """+    hwnd = _find_fusion_hwnd()+    if not hwnd:+        return {+            "success": False,+            "error": "Fusion 360 window not found (no window with 'Autodesk Fusion' in title).",+            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry.",+        }++    # Get window rect+    left, top, right, bottom = _get_window_rect(hwnd)+    width = right - left+    height = bottom - top++    if width <= 0 or height <= 0:+        return {"success": False, "error": f"Invalid window rect: {left},{top},{right},{bottom}"}++    if use_bitblt:+        # Screen DC capture — needs foreground+        SW_RESTORE = 9+        user32.ShowWindow(hwnd, SW_RESTORE)+        user32.SetForegroundWindow(hwnd)+        time.sleep(1.0)++        hdc_screen = user32.GetDC(0)+        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+        gdi32.BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, left, top, SRCCOPY)+    else:+        # PrintWindow capture — works without foreground+        hdc_screen = user32.GetDC(hwnd)+        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+        user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)++    # Read pixel data via GetDIBits+    class BITMAPINFOHEADER(ctypes.Structure):+        _fields_ = [+            ("biSize", ctypes.wintypes.DWORD),+            ("biWidth", ctypes.wintypes.LONG),+            ("biHeight", ctypes.wintypes.LONG),+            ("biPlanes", ctypes.wintypes.WORD),+            ("biBitCount", ctypes.wintypes.WORD),+            ("biCompression", ctypes.wintypes.DWORD),+            ("biSizeImage", ctypes.wintypes.DWORD),+            ("biXPelsPerMeter", ctypes.wintypes.LONG),+            ("biYPelsPerMeter", ctypes.wintypes.LONG),+            ("biClrUsed", ctypes.wintypes.DWORD),+            ("biClrImportant", ctypes.wintypes.DWORD),+        ]++    bmi = BITMAPINFOHEADER()+    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)+    bmi.biWidth = width+    bmi.biHeight = height  # positive = bottom-up (standard BMP order)+    bmi.biPlanes = 1+    bmi.biBitCount = 32+    bmi.biCompression = BI_RGB++    buf_size = width * height * 4+    pixel_buf = ctypes.create_string_buffer(buf_size)+    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)++    # Clean up GDI resources+    gdi32.SelectObject(hdc_mem, old_bmp)+    gdi32.DeleteObject(hbmp)+    gdi32.DeleteDC(hdc_mem)+    if use_bitblt:+        user32.ReleaseDC(0, hdc_screen)+    else:+        user32.ReleaseDC(hwnd, hdc_screen)++    # Save to file as PNG (try PIL first, fall back to BMP)+    os.makedirs(SCREENSHOT_DIR, exist_ok=True)+    timestamp = int(time.time() * 1000)++    try:+        from PIL import Image+        # pixel_buf is BGRA bottom-up; convert to RGBA top-down+        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")+        img = img.transpose(Image.FLIP_TOP_BOTTOM)++        # Downscale to max 1568px on longest side (saves tokens for AI vision)+        MAX_DIM = 1568+        if max(width, height) > MAX_DIM:+            ratio = MAX_DIM / max(width, height)+            new_size = (int(width * ratio), int(height * ratio))+            img = img.resize(new_size, Image.LANCZOS)++        # Try WebP first (much smaller than PNG for screenshots)+        try:+            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.webp")+            img.save(path, "WEBP", lossless=True, quality=100)+        except Exception:+            # Fall back to PNG if WebP not available+            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.png")+            img.save(path, "PNG", optimize=True)+    except ImportError:+        # No PIL — save as BMP (readable by most tools, but not Claude Read)+        path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.bmp")+        _write_bmp(path, width, height, pixel_buf.raw)++    size_kb = os.path.getsize(path) / 1024+    return {+        "success": True,+        "savedTo": path.replace("\\", "/"),+        "sizeKB": round(size_kb, 1),+        "dimensions": {"width": width, "height": height},+    }+++# --- Click ---++def click_fusion(x, y, relative=True, hwnd=None) -> dict:+    """Click at coordinates in a window (Fusion main window or any dialog).++    Args:+        x: X coordinate. If relative=True, a float 0.0-1.0 (percentage of window width).+           If relative=False, pixel offset from window's top-left corner.+        y: Y coordinate. Same convention as x but for height.+        relative: If True (default), x/y are percentages. If False, pixel offsets.+        hwnd: Optional HWND to click on. If omitted, finds the main Fusion window.+              Use this to click buttons inside Qt dialogs by passing the dialog's HWND.++    Uses SendInput (NOT PostMessage) because CEF doesn't respond to WM messages.+    """+    if hwnd:+        hwnd = int(hwnd)+        # Validate the HWND is a real window+        if not user32.IsWindow(hwnd):+            return {+                "success": False,+                "error": f"HWND {hwnd} is not a valid window.",+                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",+            }+    else:+        hwnd = _find_fusion_hwnd()+        if not hwnd:+            return {+                "success": False,+                "error": "Fusion 360 window not found.",+                "_hint": "Call fusion_start to start Fusion 360, then retry.",+            }++    left, top, right, bottom = _get_window_rect(hwnd)+    width = right - left+    height = bottom - top++    # Bring to front+    user32.SetForegroundWindow(hwnd)+    time.sleep(0.1)++    # Calculate screen coordinates+    if relative:+        screen_x = left + int(float(x) * width)+        screen_y = top + int(float(y) * height)+    else:+        screen_x = left + int(x)+        screen_y = top + int(y)++    # Convert to absolute coordinates for SendInput (0-65535 range)+    screen_w = user32.GetSystemMetrics(0)  # SM_CXSCREEN (primary monitor)+    screen_h = user32.GetSystemMetrics(1)  # SM_CYSCREEN+    abs_x = int(screen_x * 65535 / screen_w)+    abs_y = int(screen_y * 65535 / screen_h)++    # Move mouse+    move = INPUT()+    move.type = INPUT_MOUSE+    move.union.mi.dx = abs_x+    move.union.mi.dy = abs_y+    move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE++    # Click down+    down = INPUT()+    down.type = INPUT_MOUSE+    down.union.mi.dx = abs_x+    down.union.mi.dy = abs_y+    down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN++    # Click up+    up = INPUT()+    up.type = INPUT_MOUSE+    up.union.mi.dx = abs_x+    up.union.mi.dy = abs_y+    up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP++    _send_input(move, down, up)++    return {+        "success": True,+        "clickedAt": {"screenX": screen_x, "screenY": screen_y},+        "windowOffset": {+            "x": screen_x - left,+            "y": screen_y - top,+        },+        "relative": relative,+    }+++# --- Keyboard ---++def send_key_to_fusion(key: str, hwnd=None) -> dict:+    """Send a key to Fusion 360 (or a specific dialog) via SendInput.++    Args:+        key: One of: "enter", "escape", "tab", "space", "up", "down",+             "left", "right", "f1"-"f12", "backspace", "delete",+             "home", "end", "pageup", "pagedown", or a single character.+        hwnd: Optional HWND to target. If omitted, targets the main Fusion window.+    """+    if hwnd:+        hwnd = int(hwnd)+        if not user32.IsWindow(hwnd):+            return {+                "success": False,+                "error": f"HWND {hwnd} is not a valid window.",+                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",+            }+    else:+        hwnd = _find_fusion_hwnd()+        if not hwnd:+            return {+                "success": False,+                "error": "Fusion 360 window not found.",+                "_hint": "Call fusion_start to start Fusion 360, then retry.",+            }++    # Bring to front+    user32.SetForegroundWindow(hwnd)+    time.sleep(0.1)++    key_lower = key.lower().strip()+    vk = VK_MAP.get(key_lower)++    if vk is None:+        if len(key) == 1:+            # Single character: use VkKeyScanW to get the virtual key code+            vk_scan = user32.VkKeyScanW(ord(key))+            vk = vk_scan & 0xFF+            shift = (vk_scan >> 8) & 0x01+            if vk == 0xFF:+                return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}++            inputs = []+            # Press shift if needed+            if shift:+                s_down = INPUT()+                s_down.type = INPUT_KEYBOARD+                s_down.union.ki.wVk = 0x10  # VK_SHIFT+                inputs.append(s_down)++            # Key down+            kd = INPUT()+            kd.type = INPUT_KEYBOARD+            kd.union.ki.wVk = vk+            inputs.append(kd)++            # Key up+            ku = INPUT()+            ku.type = INPUT_KEYBOARD+            ku.union.ki.wVk = vk+            ku.union.ki.dwFlags = KEYEVENTF_KEYUP+            inputs.append(ku)++            # Release shift if needed+            if shift:+                s_up = INPUT()+                s_up.type = INPUT_KEYBOARD+                s_up.union.ki.wVk = 0x10+                s_up.union.ki.dwFlags = KEYEVENTF_KEYUP+                inputs.append(s_up)++            _send_input(*inputs)+            return {"success": True, "key": key, "vk": vk, "shift": bool(shift)}+        else:+            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key or single character."}++    # Named key: simple down + up+    kd = INPUT()+    kd.type = INPUT_KEYBOARD+    kd.union.ki.wVk = vk++    ku = INPUT()+    ku.type = INPUT_KEYBOARD+    ku.union.ki.wVk = vk+    ku.union.ki.dwFlags = KEYEVENTF_KEYUP++    _send_input(kd, ku)+    return {"success": True, "key": key_lower, "vk": vk}+++def close_window(hwnd: int) -> dict:+    """Close a window by sending WM_CLOSE via PostMessage.++    Unlike Escape (which many Fusion dialogs ignore — e.g. Recovered Documents),+    WM_CLOSE is the standard Windows mechanism for closing a window and is+    handled by virtually all Qt dialogs.++    This does NOT force-kill the window — the dialog can still prompt for+    confirmation. It's equivalent to clicking the X button in the title bar.++    Args:+        hwnd: HWND of the window to close.+    """+    hwnd = int(hwnd)+    if not user32.IsWindow(hwnd):+        return {+            "success": False,+            "error": f"HWND {hwnd} is not a valid window.",+        }++    WM_CLOSE = 0x0010+    user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+    return {"success": True, "hwnd": hwnd, "action": "WM_CLOSE sent"}+++# --- Window info ---++def get_fusion_window_info() -> dict:+    """Get Fusion 360 window info: hwnd, title, rect, and any Qt dialog windows.++    Returns the main Fusion window details plus a list of other Qt windows+    that may be Fusion dialogs (file pickers, recovery prompts, etc.).+    """+    hwnd = _find_fusion_hwnd()+    if not hwnd:+        return {+            "success": False,+            "error": "Fusion 360 window not found.",+            "_hint": "Call fusion_start to start Fusion 360, then retry.",+        }++    # Get title+    length = user32.GetWindowTextLengthW(hwnd)+    title = ""+    if length > 0:+        buf = ctypes.create_unicode_buffer(length + 1)+        user32.GetWindowTextW(hwnd, buf, length + 1)+        title = buf.value++    left, top, right, bottom = _get_window_rect(hwnd)++    # Find potential dialog windows+    dialogs = _find_qt_dialog_windows()++    return {+        "success": True,+        "hwnd": hwnd,+        "title": title,+        "rect": {+            "left": left, "top": top,+            "right": right, "bottom": bottom,+            "width": right - left,+            "height": bottom - top,+        },+        "dialogs": dialogs,+    }
handlers/open_design.pyadded+107
@@ -0,0 +1,107 @@+"""Handler for open_design command.++Opens a design file (.f3d, .step, .iges, etc.) in Fusion 360.+Launches Fusion if it's not already running.+Imports files via the AdomBridge add-in API when available.+"""++import json+import urllib.request+import urllib.error+from pathlib import Path++from fusion_detect import ensure_fusion_running, _is_fusion_running++ADDIN_PORT = 8774++SUPPORTED_EXTENSIONS = {+    ".f3d", ".f3z",+    ".step", ".stp",+    ".iges", ".igs",+    ".sat",+    ".stl",+    ".3mf",+    ".obj",+}+++def _import_via_addin(file_path: str, timeout: int = 30) -> dict:+    """Import a file via the AdomBridge add-in running inside Fusion."""+    body = json.dumps({"command": "import_file", "args": {"filePath": file_path}}).encode("utf-8")+    req = urllib.request.Request(+        f"http://127.0.0.1:{ADDIN_PORT}/command",+        data=body,+        headers={"Content-Type": "application/json"},+        method="POST",+    )+    try:+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            return json.loads(resp.read())+    except urllib.error.URLError as e:+        return {+            "success": False,+            "error": f"AdomBridge add-in not responding (port {ADDIN_PORT}): {e}. "+                     "Ensure the add-in is running in Fusion (UTILITIES > ADD-INS > AdomBridge).",+        }+    except Exception as e:+        return {"success": False, "error": f"Import request failed: {e}"}+++def handle_open_design(fusion_info: dict, args: dict) -> dict:+    """Open a design file in Fusion 360, or just launch Fusion.++    Args:+        fusion_info: Fusion 360 detection info dict.+        args: Dict with:+            - filePath: Path to the file to open (optional).+              If omitted or empty, just ensures Fusion is running.+    """+    file_path = args.get("filePath", "")++    # Check if Fusion is installed+    if not fusion_info.get("installed"):+        return {+            "success": False,+            "error": "Fusion 360 is not installed on this machine.",+            "errorCode": "fusion_not_installed",+            "_hint": "Fusion 360 cannot be installed programmatically. Report this to the user and ask them to install it from https://www.autodesk.com/products/fusion-360, then retry.",+        }++    # Check if Fusion is running (live process check — never auto-launch)+    if not _is_fusion_running():+        return {+            "success": False,+            "error": "Fusion 360 is installed but not running.",+            "errorCode": "fusion_not_running",+            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command.",+        }++    # Launch-only mode: no file specified, Fusion is already running+    if not file_path:+        return {"success": True, "output": "Fusion 360 is running."}++    path = Path(file_path)+    if not path.exists():+        return {+            "success": False,+            "error": f"File not found: {file_path}",+            "errorCode": "file_not_found",+            "_hint": "Verify the path with `ls` or check if the file is in the cloud instead of local — use fusion_walk_cloud_tree to find cloud files, then fusion_open_cloud_file to open them.",+        }++    ext = path.suffix.lower()+    if ext not in SUPPORTED_EXTENSIONS:+        return {+            "success": False,+            "error": f"Unsupported file type: {ext}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",+            "errorCode": "unsupported_extension",+            "_hint": "Use a supported extension, or for electronics files (.fsch/.fbrd/.flbr) use fusion_import_electronics instead.",+        }++    # Fusion is running — ensure add-in is ready (needed for import)+    err = ensure_fusion_running(fusion_info, wait_addin=True)+    if err:+        return err++    # Import the file via the AdomBridge add-in (runs inside Fusion)+    return _import_via_addin(str(path))
install_addin.pyadded+139
@@ -0,0 +1,139 @@+#!/usr/bin/env python3+"""Install the AdomBridge add-in into Fusion 360's AddIns directory.++Copies the addin/AdomBridge/ folder to:+  %APPDATA%/Autodesk/Autodesk Fusion/API/AddIns/AdomBridge/+  (or Autodesk Fusion 360 variant)++Usage:+    python install_addin.py+    python install_addin.py --uninstall+"""++import shutil+import sys+from pathlib import Path++# Source: the add-in bundled with this plugin+SOURCE_DIR = Path(__file__).resolve().parent / "addin" / "AdomBridge"++# Possible target directories+TARGET_CANDIDATES = [+    Path.home() / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",+    Path.home() / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",+]+++def _sync_directory(source: Path, target: Path):+    """Sync files from source to target, updating only changed files.++    - Copies new/modified files from source to target+    - Removes files in target that don't exist in source+    - Preserves the target directory itself (and any Fusion metadata)+    """+    import filecmp++    # Build set of relative paths in source+    source_files = set()+    for src_file in source.rglob("*"):+        rel = src_file.relative_to(source)+        source_files.add(rel)++        dst_file = target / rel+        if src_file.is_dir():+            dst_file.mkdir(parents=True, exist_ok=True)+        elif src_file.is_file():+            dst_file.parent.mkdir(parents=True, exist_ok=True)+            if not dst_file.exists() or not filecmp.cmp(str(src_file), str(dst_file), shallow=False):+                shutil.copy2(src_file, dst_file)+                print(f"  Updated: {rel}")++    # Remove files in target that aren't in source (but keep __pycache__ etc.)+    if target.exists():+        for dst_file in sorted(target.rglob("*"), reverse=True):+            rel = dst_file.relative_to(target)+            # Skip __pycache__ and .pyc files+            if "__pycache__" in str(rel):+                continue+            if rel not in source_files:+                if dst_file.is_file():+                    dst_file.unlink()+                    print(f"  Removed: {rel}")+                elif dst_file.is_dir() and not any(dst_file.iterdir()):+                    dst_file.rmdir()+                    print(f"  Removed dir: {rel}")+++def find_addins_dir() -> Path | None:+    """Find an existing Fusion AddIns directory, or return the first candidate."""+    for candidate in TARGET_CANDIDATES:+        if candidate.exists():+            return candidate+    # Return the first one even if it doesn't exist — we'll create it+    return TARGET_CANDIDATES[0]+++def install():+    """Install the AdomBridge add-in.++    Uses a smart update strategy: copies individual files from source to target+    rather than deleting the entire target folder. This preserves Fusion 360's+    "Run on Startup" setting which is stored in the AddIns directory metadata.+    """+    if not SOURCE_DIR.exists():+        print(f"ERROR: Source add-in not found at {SOURCE_DIR}")+        sys.exit(1)++    addins_dir = find_addins_dir()+    if not addins_dir:+        print("ERROR: Could not determine Fusion 360 AddIns directory.")+        sys.exit(1)++    target = addins_dir / "AdomBridge"++    print(f"Source:  {SOURCE_DIR}")+    print(f"Target:  {target}")++    addins_dir.mkdir(parents=True, exist_ok=True)++    if target.exists():+        # Smart update: sync files from source to target without deleting the folder+        _sync_directory(SOURCE_DIR, target)+        print(f"Updated AdomBridge add-in at {target}")+    else:+        # Fresh install+        shutil.copytree(SOURCE_DIR, target)+        print(f"Installed AdomBridge add-in to {target}")+        print()+        # The manifest sets "runOnStartup": true, so Fusion auto-runs the add-in+        # on its next launch — no manual enabling needed. Just (re)start Fusion and+        # the bridge's HTTP server comes up automatically; verify with+        # fusion_addin_status. (Older Fusion builds may require a one-time enable+        # via UTILITIES > ADD-INS > AdomBridge > Run on Startup if it doesn't load.)+        print("Restart Fusion 360 — the add-in auto-runs on startup (runOnStartup:true).")++    print()+    print("The add-in will start an HTTP server on port 8774.")+++def uninstall():+    """Uninstall the AdomBridge add-in."""+    for candidate in TARGET_CANDIDATES:+        target = candidate / "AdomBridge"+        if target.exists():+            shutil.rmtree(target)+            print(f"Removed: {target}")+            return++    print("AdomBridge add-in is not installed.")+++def main():+    if "--uninstall" in sys.argv:+        uninstall()+    else:+        install()+++if __name__ == "__main__":+    main()
server.py+7−3
@@ -23,7 +23,7 @@ sys.path.insert(0, str(Path(__file__).parent))  from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running from handlers.open_design import handle_open_design-from handlers.close_fusion import handle_close_fusion+from handlers.close_fusion import handle_close_fusion, handle_fusion_stop, handle_fusion_kill from handlers.dismiss_recovery import dismiss_recovery_dialog from handlers.fusion_ui import (     screenshot_fusion_window,@@ -238,6 +238,7 @@ def _post_open_screenshot(result: dict, settle_time: float = 2.0) -> dict:         f"Auto-captured {len(screenshot_info['screenshots'])} Fusion window(s) after open. "         "READ EACH screenshot file to check for blocking dialogs "         "('What to design?', 'PCB out of date', 'Save changes?'). "+        "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. "         "Dismiss with fusion_send_key {\"key\": \"escape\"} if needed."     ) @@ -465,7 +466,9 @@ def _handle_relocate_recovery(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS = {     "describe": lambda fi, args: describe.handle_describe(args),     "open_design": handle_open_design,-    "close": handle_close_fusion,+    "close": handle_close_fusion,  # deprecated alias - use stop (graceful) / kill (force)+    "stop": handle_fusion_stop,    # graceful: close docs + WM_CLOSE, NO force-kill+    "kill": handle_fusion_kill,    # force: taskkill /F (the desperate path)     "launch": _handle_launch,     "start": _handle_launch,  # alias — CLI's fusion_start delegates here on Docker     "dismiss_recovery": _handle_dismiss_recovery,@@ -574,6 +577,7 @@ ADDIN_COMMANDS = {     "take_screenshot",     # Electronics commands (EAGLE via Electron.run)     "electron_run", "execute_text_command",+    "electron_zoom", "electron_pan", "electron_select",  # video-friendly view control     "open_electronics", "list_text_commands",     # Electronics source export (.fsch, .fbrd, .flbr via Document.CopyToDesktop)     "export_source",@@ -1075,7 +1079,7 @@ def dispatch_command(command: str, args: dict) -> dict:     # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.     # The user should launch Fusion themselves; we just report the status.     # Commands that need Fusion running (add-in commands + orchestrated commands)-    _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "import_electronics"}+    _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}     if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr") or command in _OPEN_WITH_SCREENSHOT:         if not fusion_info.get("installed"):             return {
skills/fusion-aps-search/SKILL.mdadded+93
@@ -0,0 +1,93 @@+---+name: fusion-aps-search+description: THE definitive reasoning for the Fusion bridge's APS cloud-file search — WHY we use Autodesk Platform Services (APS) Data Management for fast search, why the slow Fusion add-in search is unacceptable, why the bridge runs its own OAuth, and the hard payment rules. READ THIS FIRST before proposing ANY change to fusion cloud search, before "reconsidering" APS, or before suggesting a fallback. Stops the recurring loop of re-deriving the same conclusions. Trigger words — fusion cloud search, fusion_aps_search, aps search, why aps, fusion search slow, fusion_search_cloud_files, fusion_walk_cloud_tree, search fusion files, cloud file search, fusion data management.+---++# Why the Fusion bridge uses APS for cloud search (settled — do NOT relitigate)++This exists because the reasoning kept getting re-derived (and re-asked of John, who is+sick of answering). It is SETTLED. Read it, follow it, don't reopen it.++## The goal+Adom users need to search their Fusion 360 **cloud files FAST** — by name — for every Adom+user, with AI-driven setup. "Fast" = seconds, not minutes.++## The garbage we do NOT use (and must NOT propose as the answer)+`fusion_search_cloud_files` and `fusion_walk_cloud_tree` exist in the bridge but are+**UNACCEPTABLE for search**: they go through Fusion's free add-in Data API, which is so+rate-limited that walking a real project takes **30+ MINUTES**. They are garbage for this+use case. **NEVER** fall back to them as "the free option" — slowness this bad is a+non-starter. If you catch yourself about to recommend "just use the add-in search since it's+free / no card," STOP — that's the exact wrong conclusion John has corrected multiple times.++## The answer: APS Data Management API (server-side index)+Autodesk Platform Services' **Data Management API** has a **server-side index** → name search+returns in seconds. This is the ONLY way to get fast search. This is why APS exists in the+bridge. Speed is the whole point; APS is how we get it.++## Why the bridge runs its OWN OAuth (can't piggyback Fusion's login)+We WANTED to reuse Fusion's existing in-session token (no setup, no second login). It's+**blocked**: Autodesk DPAPI-encrypts Fusion's on-disk token store+(`%LOCALAPPDATA%\Autodesk\Identity Services\idservices.db`, `products`/`mcptokens` tables)+**with app-specific entropy** → `CryptUnprotectData` fails `gle=13` (ERROR_INVALID_DATA). You+cannot extract/reuse Fusion's token without reverse-engineering Autodesk's entropy (fragile,+ToS-violating, and credential-extraction tooling we will not build). So the bridge runs its+OWN 3-legged **PKCE OAuth** (public client, NO client secret) → `fusion_aps_*` verbs. This is+final; don't re-investigate token reuse.++## The payment rules (HARD — non-negotiable)+The new APS portal gates app creation behind a developer hub + an APS **plan**, and even the+**Free tier requires a payment method "to verify identity."**+- ✅ **A credit card on file for IDENTITY VERIFICATION is acceptable.**+- ⛔ **A user (or John, or Adom) being CHARGED ANY MONEY is NEVER acceptable.** "It's insane+  to get charged just to search your own files."+- ⛔ Therefore the bridge **MUST monitor usage and HARD-CAP before the Free-tier limit is+  reached** — it must be *impossible* for a user to accidentally exceed the free allotment and+  incur a charge. Build the meter + cutoff in; fail closed (stop searching) before any paid+  usage. (TODO to verify + bake in: confirm whether Data Management search is in APS's+  always-free API set or consumes paid tokens; cap accordingly. Until verified, treat it as+  capped.)++## Team model — PER-ORG setup; within an org, one admin sets up for everyone (critical)+The unit of setup is the **organization/company**, NOT the whole Adom platform:+- **Each org that uses Adom sets up its OWN APS app + Free plan + APS Developer Hub, ONCE.**+  Why: each org has its own separate Autodesk cloud data, and each org should bear its own+  free-tier quota (one org's usage must not eat another's, and Adom must not foot every org's+  API quota). So the Client ID is **per-org**, configured via `fusion_aps_set_client_id` (or+  baked into that org's bridge deployment) — do NOT ship ONE global Client ID for all Adom+  platform users.+- **WITHIN one org**, though, it's the big win: ONE admin does the plan→dev-hub→app→Client-ID+  setup, and **every employee of that org** just runs `fusion_aps_signin` ONCE (their own+  Autodesk login + one OAuth consent) — **no card, no plan, no hub, no app setup** for them.+  The Client ID is a **public PKCE identifier (no secret)**, so it's safe to share among that+  org's users. API usage bills to that org's single **Free** plan.+- This is exactly why the setup must be AI-DRIVEN: every org's admin runs the SAME annoying+  flow once, so the AI should drive it end-to-end per org.+- **TWO different things both called "hub" — do NOT conflate them:**+  - The team's **data/Fusion hub** (e.g. "Adom", with a shared folder like **"Main"**) = the+    team's cloud FILE STORAGE — the data we search. Pre-existing; NEVER create or touch it.+  - An **"APS Developer Hub"** (e.g. "Adom APS") = a credential container that only holds the+    developer APP/Client ID. Holds ZERO files. Created once by the admin.+  - The developer hub does NOT limit what's searchable. 3-legged OAuth means the app reads+    everything the signed-in USER can access — including the team's existing shared data hub+    and folders. So search hits the real shared team files regardless of which dev hub the app+    lives in. (Almost every Adom user already shares an Autodesk cloud hub — search "just+    works" against it once they sign in.)+- Net: admin does plan→dev-hub→app→Client-ID ONCE; ship the Client ID as the bridge default;+  every user does one `fusion_aps_signin` and searches the shared team files fast.++## Ecosystem + automation requirement+- Must work for **ALL Adom users**, not just John.+- The whole setup — APS account, Free plan, developer hub, app registration, OAuth — is+  **annoying and complex, so it MUST be fully AI-DRIVEN** end-to-end. The user does at most+  two things: enter their own card (ID verification) and approve the OAuth consent. Everything+  else the AI drives (see the `fusion-aps-signin` skill for the browser-driving HOW).++## The recurring loop to STOP having+The trap (John has corrected it >3×): hit the APS card/plan gate → conclude "this is too+much, let's just use the free add-in search" → John reminds you the add-in search is garbage+(30+ min) and APS is the way and the card-for-ID is fine and we cap usage. **Do not enter+this loop.** APS is the path. The card is fine (ID only). We cap usage so nobody is charged.+Drive the setup with AI. Done.++Related: [[project_adom_desktop_fusion_bridge]], skill `fusion-aps-signin` (the setup HOW).
skills/fusion-aps-signin/SKILL.mdadded+247
@@ -0,0 +1,247 @@+---+name: fusion-aps-signin+description: Drive the Autodesk sign-in flow in the user's real browser to set up APS (Autodesk Platform Services) for the Fusion bridge's fusion_aps_* cloud search — open a dedicated browser window, foreground it, MONITOR the sign-in, and auto-retrieve + enter the emailed one-time passcode (OTP) via adom-google before it expires. Use when signing into Autodesk/APS, registering an APS app, configuring fusion_aps_search, or any "log me into Fusion/Autodesk for APS" request. Trigger words — aps signin, sign in to autodesk, autodesk otp, fusion aps login, register aps app, aps client id, fusion_aps_signin, autodesk verification code, link google account autodesk.+---++# Fusion / APS sign-in (driven, with auto-OTP)++Goal: get the user signed into **aps.autodesk.com** in their real browser so you can+register an APS app and configure the Fusion bridge's `fusion_aps_*` search. The hard+part is Autodesk's **email OTP**, which **expires fast** — the user may walk away ("get a+cup of coffee"), so you must **monitor** the flow and turn the code around in seconds.++All browser steps use the native-browser extension verbs (`nbrowser_*`) against the+AD-connected laptop: `adom-desktop --target <laptop> nbrowser_* '{...}'`.++## Working principles (non-negotiable)++- **DO IT YOURSELF. NEVER ask the user to do anything manually that you can do.** That is the+  entire point of the Adom AI ecosystem — the AI drives, the user doesn't. Don't hand the+  user a "go create the app and paste me the Client ID" cop-out; drive the whole flow+  (open windows, fill forms, fetch the OTP, click through) to completion yourself. Only+  surface a real decision (a genuine fork) — never offload mechanical work.+- **NEVER navigate the user's CURRENT page/window away from them** — that hijacks their live+  work (e.g. their Edge tab) and is unacceptable. **ALWAYS open a NEW window**+  (`nbrowser_open_window {url}`) for whatever you need and drive ONLY that window's+  `sessionId`. Opening your own windows and driving them is expected; reusing/navigating a+  window the user is viewing is not. `desktop_bring_to_front` to surface a window for the+  user is fine.++## Golden rules (learned the hard way)++1. **OTP expires in ~a few minutes. Be FAST.** Don't go fetch-and-think between resend and+   entry — fetch the code and enter it in the same burst. We burned the 1st code dithering;+   3rd round worked by resending and entering immediately.+2. **MONITOR the sign-in, don't fire-and-forget.** Poll the page state every few seconds+   (the user may step away). Detect each transition: email → OTP → "code expired" →+   "account already exists" → "linked" → portal. Never assume; re-read the page.+3. **Open a DEDICATED browser window** with `nbrowser_open_window` and **target it by+   `sessionId`** in every `nbrowser_eval/type/click`. The extension **refuses to drive PWA /+   installed-app windows** (e.g. "Hydrogen - Chat") and `nbrowser_eval` defaults to the+   foreground tab — which is often the wrong (PWA) window.+4. **Foreground it with `desktop_bring_to_front`** (the AD verb) so the user can see/act on+   the login. `nbrowser_focus_window` is NOT implemented.+5. **Proactively use adom-google.** Autodesk emails the OTP — you can read it via the+   `adom-google` Gmail API passthrough. If adom-google isn't set up, TELL the user it exists+   and offer to set it up (`adom-google auth`) so you can fetch the code for them. Gmail for+   Adom = **[email protected]** (work account).++## Step-by-step++### 1. Open + foreground a dedicated window at the APS portal+```+adom-desktop --target <laptop> nbrowser_open_window '{"url":"https://aps.autodesk.com/myapps/"}'+# capture sessionId (e.g. s29) from the result; use it in EVERY later call+adom-desktop --target <laptop> desktop_bring_to_front '{...the browser window...}'   # so the user sees it+```+Read state (always pass the sessionId):+```+nbrowser_eval '{"sessionId":"s29","expression":"JSON.stringify({url:location.href,title:document.title,body:document.body.innerText.slice(0,300)})"}'+```+If `location.href` is `signin.autodesk.com`, the profile has **no active Autodesk session** —+the user must start sign-in (enter email, or an SSO button). You won't type their password.++### 2. Monitor for the OTP screen+Poll `nbrowser_eval` every ~3-5s. The OTP page title is **"Linkable email OTP - Autodesk"**+with six single-char boxes `input[name=pin-1..pin-6]` and body "Enter the code from your+email. … Email sent."++### 3. Fetch the freshest OTP from Gmail (adom-google)+Check auth first: `adom-google status` (need a refresh token + gmail scope). Then:+```bash+# list newest Autodesk passcode mail (strip the leading "HTTP 200 ..." line)+MID=$(adom-google api "https://gmail.googleapis.com/gmail/v1/users/me/messages" \+      -q "q=from:autodesk subject:passcode newer_than:1h" -q "maxResults=1" \+      | tail -n +2 | python3 -c "import sys,json;print(json.load(sys.stdin)['messages'][0]['id'])")+# get it, pull the 6-digit code from the snippet ("Code: 205753")+adom-google api "https://gmail.googleapis.com/gmail/v1/users/me/messages/$MID" -q "format=full" \+  | tail -n +2 | python3 -c "import sys,json,re;d=json.load(sys.stdin);print(re.search(r'Code:\s*(\d{6})',d['snippet']).group(1))"+```+Always take the **newest** message (the email footer also contains `000000`/`212121` noise —+match `Code:\s*(\d{6})`, not any 6 digits).++### 4. Enter the code — digit-by-digit (boxes are maxlen-1, NO auto-advance)+Bulk-typing the whole string only fills box 1. Focus each box, type its digit:+```bash+for i in 1 2 3 4 5 6; do+  D=$(echo "$CODE" | cut -c$i)+  nbrowser_eval  '{"sessionId":"s29","expression":"document.querySelector('input[name=pin-'$i']').focus()"}'+  nbrowser_type  '{"sessionId":"s29","text":"'$D'"}'+done+nbrowser_click '{"sessionId":"s29","text":"Next"}'+```+Verify before submitting: read `[...].map(i=>i.value).join("")` == the code.++### 5. If "This code has expired" → resend + redo FAST+```+nbrowser_click '{"sessionId":"s29","text":"Resend"}'+# wait ~12s, fetch the NEWEST code (step 3), enter immediately (step 4)+```++### 6. "Account already exists" (Google-federated account)+Body: "An Autodesk account already exists with the email … sign in with Google … link …".+Click **"Link Google account"**. If the Google session is already active in Chrome it links+instantly ("Google account linked … Continue in 5 seconds") and lands in the portal — no+account picker. If a Google picker appears, pick **[email protected]**.++### 7. First-time APS → accept Terms of Service+First portal visit shows "Welcome aboard! … accept our Terms of Service". Accept it.++### 7.5 Developer-profile form (REQUIRED before you can create an app — you must fill it)+First-time APS gates app creation behind a one-time **developer/company profile** at+`https://aps.autodesk.com/myapps/create` ("Ready for next steps?"). YOU fill it from the+user's profile memory (`user_profile.md` — company, website, address). Fields seen:+`company`, `website`, `country`, `stateProvince`, `city`, `postalCode` (plain text) and+`roleId`, `accountPurpose` (**MUI comboboxes**) + a `tosAgreed` checkbox.+- Plain text: set via the native value setter + dispatch `input`+`change`+`blur` (MUI only+  enables Submit after these fire):+  ```+  nbrowser_eval set vals {company:"Adom Industries, Inc.",website:"https://adom.inc",+    city:"Fort Worth",stateProvince:"TX",postalCode:"76177"}  // country prefills "US"+  ```+- Comboboxes — can't just set `.value`; click to open, then **click the option by text**+  (click-by-text finds it even when not in the visible top-N; typing to filter often does+  NOT work). The fields and good picks for Adom:+  - `roleId` = **Business owner/Entrepreneur** (John is the founder)+  - `accountPurpose` = **Business**+  - **Industry (select up to 3)** — REQUIRED, and it's the one that silently blocks Submit.+    It has NO `name` attr (MUI multiselect) — find it by its label "Industry (select up to 3)"+    (map each input to its nearest ancestor's label text + getBoundingClientRect, then click+    the field's center coords). Pick **Industrial machinery** (Adom = PCB/electronics mfg).+- Tick `tosAgreed` (click the "I agree to the Autodesk Platform Services" label). **It+  re-resets to false after combobox interactions / re-renders — re-verify it's checked right+  before Submit.**+- **Submit silently no-ops while ANY required field is invalid, with the button still+  `enabled` and NO scrape-able error.** When Submit won't advance, **take a screenshot+  (`nbrowser_screenshot {sessionId}`) and READ it** — inline "Please fill out this field"+  tooltips (e.g. the empty Industry box) only show visually, not in the DOM you're scraping.+  This is the fastest way to find a missed required field.+- Field coords from `getBoundingClientRect` are in PAGE space (what `nbrowser_click {x,y}`+  wants) — do NOT use screenshot pixel coords (the shot is downscaled, different origin).+Known profile values (Adom / John): Company "Adom Industries, Inc.", Website https://adom.inc,+4400 Alliance Gateway Fwy, Fort Worth, TX 76177, US, [email protected].++### 7.55 Get the FREE APS plan (drive the whole checkout yourself — incl. the card)+Newer APS gates app creation behind a **developer hub**, which requires an **APS plan**. The+**Free tier is $0** but Autodesk requires a **payment method "to verify identity"** (no charge).+**The AI drives 100% of this**, including entering the card. Live-verified flow:+1. New window → `https://aps.autodesk.com/hubs/` → the "It seems you don't have a hub yet"+   page → **Step 1 "Get an APS plan" → click "View options"** (or go straight to+   `https://www.autodesk.com/products/autodesk-platform-services/overview`).+2. On the pricing tiers (3 columns: **Free** | Prepay $99 | Pay-as-you-go), click the+   **leftmost "Add to Cart"** = the **Free** tier. (Get all `Add to Cart` rects, sort by x,+   take the smallest — Free is the left column. NEVER click the paid columns.)+3. Cart drawer shows **"APS Free — $0.00", SUBTOTAL $0.00**. Click **Check out**.+4. `checkout.autodesk.com` — a 3-step accordion: **Cart → Customer details → Payment**, with+   **Order summary TOTAL $0.00**. Click **Continue**; an abandon-cart modal ("Don't leave your+   cart behind!") may pop — click **CONTINUE CHECKOUT**, then **Continue** again. Customer+   details auto-fill from the Autodesk account. It lands open on **Payment**.+5. ⚠️ **VERIFY TOTAL = $0.00 before doing anything in Payment.** If it's ever nonzero, STOP —+   never proceed (hard rule: no user is ever charged).+6. **Payment — almost always a card is ALREADY on file (no card entry needed).** **Almost+   every Adom user has a PAID Fusion 360 subscription**, which means Autodesk already has their+   payment method on file. So the Payment step will typically show an **existing card** —+   **SELECT it** and move on. The AI completes this with **zero** card handling and zero user+   input. Only in the rare case of NO card on file do you fall back to collecting one from the+   user (their own card, ID-verification only; **TREAT AS SENSITIVE — never echo/store/log+   it**). Either way, then click **Submit order** (agrees to Autodesk's $0 Online Purchase+   Terms; auto-renews at $0).+7. After the $0 order confirms, continue to the hub.++### 7.6 Create the developer hub (manage.autodesk.com)+Per Autodesk docs: `manage.autodesk.com` → **Products and Services → Hubs → Create hub** →+select **APS developer hub** → enter a name (e.g. "Adom") + optional description → **Create+and Activate**. (The account may already show an org hub named "Adom" at+`manage.autodesk.com/hubs-list`; if a usable APS developer hub already exists, use it.)+If routes 404 / loop, open a FRESH window and retry — don't blind-click, and don't offload it.++### 7.7 (was 7.6) Register the app — drive it, don't hand it off+After the profile, newer APS routes app creation through a **developer hub**:+`/myapps/create` → `/hubs/@personal/applications/create` may 404 ("Not found"), and `/myapps`+redirects to "you don't have a hub yet" with a **Create hub** button that bounces to+`manage.autodesk.com/hubs-list` (a DIFFERENT portal; the account may already list an org hub+like "Adom"). This area is non-linear and account-structural. If it 404s / loops, **don't blind-click** —+open a FRESH window, reload, and work the hub→app path yourself (create/select the hub, then+the app). Drive it to a Client ID yourself; do NOT offload it to the user. Once you have the+Client ID, `fusion_aps_set_client_id` → `fusion_aps_signin` → `fusion_aps_search`.++### 8. Register the APS app (then configure the bridge)+Create App → enable **Data Management API** → **Callback URL =+`http://localhost:8917/fusion-aps/callback`** (must match the bridge's `REDIRECT_URI`+EXACTLY) → create → copy the **Client ID** (PKCE = public client; the secret is unused).+Then:+```+adom-desktop --target <laptop> fusion_aps_set_client_id '{"clientId":"<CLIENT_ID>"}'+adom-desktop --target <laptop> fusion_aps_signin '{}'      # opens browser; bg listener on :8917 stores the token+# poll fusion_aps_status until {"data":{"signedIn":true,"tokenLive":true}}+adom-desktop --target <laptop> fusion_aps_search '{"query":"<part name>"}'+```++## LIVE-VERIFIED end-to-end run (2026-06-26, Adom org) — the exact sequence+This whole flow was driven live and works. Replicate it per org:+1. **Sign in** (§1–7): new window → aps.autodesk.com → email → OTP from Gmail (adom-google) →+   "Account already exists" → **Link Google account** → lands in APS.+2. **Dev profile** (§7.5): fill from user profile; Industry is REQUIRED (screenshot to catch+   it); role=Business owner, purpose=Business, Industry=Industrial machinery; Submit.+3. **Free plan** (§7.55): aps.autodesk.com/hubs → "Get an APS plan" → View options → pricing:+   **leftmost "Add to Cart" = Free** → cart shows "APS Free $0.00" → Check out →+   checkout.autodesk.com 3-step accordion (Cart→Customer details→Payment), **TOTAL $0.00** →+   Continue (+ "CONTINUE CHECKOUT" on the abandon-cart modal) → **Payment step almost always+   already shows an existing card on file** (Fusion subscribers) ✅ → **Submit order** → "Order+   Confirmation". $0, existing card, no card entry.+4. **APS Developer Hub** (§7.6): manage.autodesk.com → Products and services → **Hubs** →+   **Create hub ▾ → APS Developer Hub** → name "Adom APS" → **Create & Activate** (takes a+   minute). NOTE: this is a CREDENTIAL container with ZERO files — NOT the team's data hub.+5. **Register the app** (§7.7): aps.autodesk.com/myapps → hub now selectable → **Create+   application** → Name "Adom Fusion Bridge" → app-type = **"Desktop, Mobile, Single-Page App"**+   (radio value `native` = **Authorization Code with PKCE**) → Create. ⭐ PKCE app type =+   **"Paid APIs are unavailable"** → structurally can't be charged (1st never-charge lock).+6. **Callbacks + APIs**: in App settings → Callback URL section, the default is+   `http://localhost:8080/`. Click **"+ Add URL" 5×** (→ 6 fields), fill all 6 with the+   bridge's registered set (`all_registered_callbacks()`): localhost+127.0.0.1 × ports+   8917/8918/8920. API Access defaults to all free APIs incl. **Data Management** (keep). Click+   **Save changes** (find it by coords — click-by-text can hit the wrong element).+7. **Client ID**: App settings → Client Credentials → copy the **Client ID** (public PKCE id;+   NO secret). Extract from the DOM, not a pixel read.+8. **Wire + sign in**: `fusion_aps_set_client_id {clientId}` → `fusion_aps_signin` (bridge+   opens consent, listens on the bound port) → poll `fusion_aps_status` until `signedIn:true` →+   `fusion_aps_search {query}` → prove it returns files from the team's shared **Main** folder.++## Never-charge — TRIPLE locked (don't weaken any of these)+1. The **PKCE app type cannot use paid APIs** (Autodesk-enforced).+2. The bridge's **free-endpoint allowlist** (`/project/v1/`, `/data/v1/` only).+3. The bridge's **monthly hard cap** (fails closed). Card on file = identity verification only.++## Port choice for the callback+Check availability LIVE on the box (no AD verb exists): `python` bind-test on 127.0.0.1 ++`netsh int ipv4 show excludedportrange protocol=tcp`. 8917/8918/8920 verified free + outside+Windows reserved ranges (5357, 27339, 50000-50059). The box runs ~67 listeners — don't assume.++## Gotchas+- `nbrowser_eval/type/click` without `sessionId` hit the foreground tab → often the wrong+  (PWA) window. Always pass the dedicated `sessionId`.+- `adom-google api` prints a `HTTP 200 GET …` header line before the JSON — `tail -n +2`.+- The bridge relay only forwards `output/data/success/error/_hint` — irrelevant here but+  relevant when reading `fusion_aps_*` results (fields live under `data`).+- Don't auto-enter the user's Autodesk **password** or drive their Google **consent** —+  identity decisions are theirs; automate the OTP + form-filling, not the credentials.
skills/fusion-bridge-dev/SKILL.mdadded+78
@@ -0,0 +1,78 @@+---+name: fusion-bridge-dev+description: How to develop, version, and PUBLISH the Adom Desktop Fusion 360 bridge — where the source canonically lives, and the rule to ALWAYS publish to the wiki AND push the GitHub backup on every change. Read before editing/shipping the fusion bridge. Trigger words — fusion bridge dev, publish fusion bridge, fusion bridge repo, where does the fusion bridge live, ship fusion bridge, bump fusion bridge, adom-desktop-fusion-bridge.+---++# Fusion bridge — dev + publish workflow++## You OWN this bridge (do NOT file your own bugs as adom-desktop requests)+AD core only HOSTS/LOADS this bridge: the `adom-desktop` CLI, the relay/passthrough, bridge lifecycle++ streaming this bridge into `…\bridges-cache\fusion360`, and the core `desktop_*` verbs. **Everything+`fusion_*` is yours** - the verbs + their `_hint`s, the APS integration (auth/config/quota; port 8910+is AD's vestigial native APS, this bridge uses 8917), the add-in code AND its deployment into Fusion,+the SKILL.md, the publish loop. A `fusion_*` bug is a bug in THIS repo: fix it here and republish,+never as an adom-desktop discussion ask. (See the repo `CLAUDE.md`.)++## How the add-in actually deploys (VERIFIED by shipping v1.5.1, 2026-06-28)+Fusion loads the add-in from `%APPDATA%\Roaming\Autodesk\Autodesk Fusion[ 360]\API\AddIns\AdomBridge\`,+NOT from this repo or the cache. `install_addin.py` (`_sync_directory`) copies `addin/AdomBridge` there.+**`bridge_install` only updates the CACHE (`method: in_place_merge`); it does NOT push the add-in into+Fusion.** And the sync canNOT overwrite the add-in while **Fusion is running** (it holds the files open),+so a bridge-server respawn alone does nothing. The exact sequence that worked (after the wiki publish):+1. `bridge_install {"manifestUrl": ".../adom-bridge-fusion-manifest.json"}` (add `"force": true` to re-merge).+   Its OWN output says: "AD reaps + respawns it from the new cache on the next call (no manual kill needed)."+   So do NOT run `bridge_kill` - AD restarts the bridge SERVER (new server.py/describe.py) automatically on+   the next verb. (I ran `bridge_kill` once here; it was pointless.)+2. **`fusion_stop`** (graceful; `fusion_kill` if wedged) - REQUIRED, releases the add-in file locks. Skipping this leaves the OLD add-in live.+4. Run the cache's `install_addin.py` (`cd <cache>\fusion360 && python install_addin.py`); it prints+   `Updated: commands\cloud_documents.py ...`.+5. **Verify by SHA256** (`Get-FileHash` on the Roaming file == `sha256sum` of that file unzipped from the+   published v<ver>.zip). NEVER use `findstr` - it false-negatives on these UTF-8 files (dash/box chars).+6. `fusion_start`, then confirm a `fusion_*` verb behaves new.+- A bumped `BRIDGE_VERSION` does NOT prove the running add-in changed - always hash-verify (step 5).+- NEVER hand-edit the cache or the deployed Roaming copy - edit this source, publish, reinstall.++## Canonical source = the wiki page (the ONE source of truth)+**`https://wiki.adom.inc/adom/adom-desktop-fusion-bridge`** (owner `adom`, public). A wiki+page IS a git repo; this is THE main repo for the bridge. AD installs the bridge from here+(its manifest + zip). Every version ships here.++- Local working clone: `~/project/adom-desktop-fusion-bridge`.+- Files on the page: `bridge.json`, `BRIDGE_VERSION`, `README.md`, `SKILL.md`, `aps.py`,+  `server.py`, handlers/addin, the `adom-bridge-fusion-manifest.json` + version zips+  (`adom-bridge-fusion-v<ver>.zip`), `adom-bridge-fusion-source.tar.gz`, and+  `adom-bridge-fusion.bundle` (portable full git history).++## GitHub backup — ALWAYS push it too+`github.com/adom-inc/adom-desktop-fusion-bridge` (renamed from the old `adom-bridge-fusion`+to match the wiki slug). It is a **backup mirror**. **On EVERY change you publish to the+wiki, also `git push` to this GitHub repo.** Never let them drift again (they were 6 commits+out of sync once — that's the confusion we're preventing). Wiki = canonical, GitHub = backup,+keep them in lockstep.++## The publish ritual (every version)+1. Edit in `~/project/adom-desktop-fusion-bridge`. Bump `BRIDGE_VERSION` + `bridge.json`+   `version` together.+2. `git commit` locally.+3. **Push to GitHub backup:** `git push origin main`.+4. Publish to the wiki via the raw `/files` API (the wiki CLIs are dead) — token+   `${ADOM_WIKI_TOKEN:-$(cat /var/run/adom/api-key)}`:+   `curl -X POST -H "Authorization: Bearer $TOK" -F "file=@<f>" \+    https://wiki.adom.inc/api/v1/pages/adom-desktop-fusion-bridge/files`+   Upload: the new `adom-bridge-fusion-v<ver>.zip`, the `adom-bridge-fusion-manifest.json`+   (`{manifest_version,name,version,url,sha256,size,released_at}`, url = absolute `/files/`+   path), `bridge.json`, `BRIDGE_VERSION`, a fresh `adom-bridge-fusion-source.tar.gz`+   (`git archive`) and `adom-bridge-fusion.bundle` (`git bundle create … --all`).+5. **ANON-VERIFY**: re-download the zip + sha256-match, fetch the manifest, confirm version.+6. Install on a Fusion box: `adom-desktop --target <laptop> bridge_install+   '{"manifestUrl":".../adom-bridge-fusion-manifest.json"}'`, then reap the old instance+   (`bridge_kill` on AD ≥1.8.187, else taskkill the PID on its runtimePort) so the new code+   loads, and verify via `bridge_log_read` / a `fusion_*` verb.++## The stale seed — do NOT publish from it+`adom-desktop` (private GitHub) → `plugins/fusion360/` was the bridge's ORIGINAL home and is+now a **frozen first-run fallback only** (AD bundles it offline). It is STALE and NOT+canonical. `release-bridge.sh` refuses to publish it. There is a `STALE.md` in that folder+pointing here. Never treat `plugins/fusion360` as the source; never publish from it.++Related: [[project_adom_desktop_fusion_bridge]], skills `fusion-aps-search`, `fusion-aps-signin`.
skills/fusion-electronics/SKILL.mdadded+78
@@ -0,0 +1,78 @@+---+name: fusion-electronics+description: How to drive Fusion 360 Electronics (the EAGLE-based schematic + PCB editor) through the Adom Fusion bridge - the file hierarchy (open the PROJECT, never the schematic/.brd/3D), switching between schematic / 2D board / 3D PCB views, and moving the editor for demos and screen recordings (zoom, pan, frame a region, orbit the 3D board, select parts, surface the properties panel). Read before opening or recording any PCB. Trigger words - fusion electronics, fusion pcb, open the board, open the schematic, fusion_show_2d_board, fusion_show_3d_board, eagle editor, zoom the board, pan the board, orbit the 3d pcb, select a part, properties panel, record the schematic, record the board, board out of date, empty 2d board, EcadDesignProductType, parent child electronics design.+---++# Fusion Electronics - drive the schematic, board, and 3D PCB++## 1. ALWAYS open the PROJECT, never a child file++A Fusion electronics design is NOT one file. It is a parent/child chain of separate files, each a+distinct `productType`:++```+PROJECT        EcadDesignProductType    <- OPEN THIS ONE+  |- schematic   SchematicProductType+  |- board/.brd  BoardProductType+  |- 3D PCB      DesignProductType        (generated FROM the .brd - the LEAF; opening it gives NO editable board)+```++- Open the PROJECT with `fusion_open_cloud_file` / `fusion_open_by_urn`. Then the schematic and board+  are children you switch between - they are VIEWS of the one design, not separate things to open.+- **Opening a schematic / .brd / 3D directly** gives an isolated or empty view, and you cannot derive+  the board from the 3D (it is the leaf). The open verbs **reprimand you in the response `_hint`** when+  the opened file is not `EcadDesignProductType` - read it and reopen the project.+- Same-named copies in OTHER projects (e.g. a drone assembly that imported the board) are usually+  3D-model derivatives (`DesignProductType`), not the project. Do not open those.+- An EMPTY 2D board (`fusion_board_info` returns 0 elements) almost always means you opened the wrong+  file. Reopen the parent PROJECT and switch to its board.++## 2. Switch between the views++The design is open; switch the LIVE editor between its three views with the symmetric trio:+- `fusion_show_schematic` - the schematic (circuit diagram). Uses Fusion's `SwitchSchDocCmd`.+- `fusion_show_2d_board` - the 2D PCB Editor (board layout / copper). `SwitchPcbDocCmd`.+- `fusion_show_3d_board` - the populated 3D PCB. `Switch3dPcbDocCmd`.++All three move the VISIBLE editor (not just the API's active doc), so they're what you use to set up a+recording of a given view. Schematic queries also run via the `Electron.sch_*` text commands. These are+VIEWS of the open project - if a view is empty, you opened a child/derivative (see section 1).++## 3. Move the editor for demos / screen recordings++Give a recording "life" with these FIRST-CLASS verbs. Each runs its WHOLE motion inside the add-in -+one call = one smooth move, a repaint per frame (the 2D analogue of `vp.refresh()`):++### Zoom (schematic / 2D board)+`fusion_electron_zoom {factor, steps?, frameDelayMs?}` - `factor>1` zooms in, `<1` out; or `{fit:true}`.+  e.g. `adom-desktop fusion_electron_zoom '{"factor":3,"steps":20}'` (smooth zoom in over 20 frames).++### Pan + frame a region (or a part)+`fusion_electron_pan {x1,y1,x2,y2, steps?, frameDelayMs?}` - glide+zoom to a board-coordinate box (mm).+Animates from where the view last was. Get part coordinates from `fusion_board_info` or the CPL export+(`fusion_export_cpl` gives every part's Mid X / Mid Y) - frame a small box around a part to "go to" it.+  e.g. `adom-desktop fusion_electron_pan '{"x1":10,"y1":15,"x2":22,"y2":38,"steps":16}'`.++### Select a part + surface its properties+`fusion_electron_select {name}` (reference designator) or `{x,y}` (board mm) - selects the part the way+clicking it reveals the properties panel, and returns its device info. (Select highlights; pair with+`fusion_electron_pan` to also center on it - the "select chip -> zoom to it" tour.)+  e.g. `adom-desktop fusion_electron_select '{"name":"U1"}'`.++### Orbit / zoom the 3D PCB (Fusion camera)+`fusion_show_3d_board`, then `fusion_run_modeling_script` with a camera loop: `app.activeViewport.camera`,+step the eye around the target in azimuth (vary radius to zoom), `cam.isSmoothTransition=False`,+`vp.camera=cam`, **`vp.refresh()`** + `adsk.doEvents()` + a short `time.sleep()` each frame. The+`vp.refresh()` is what makes the in-between frames render.++### Lower-level building blocks (what the verbs above wrap)+`fusion_electron_run` runs raw EAGLE: `WINDOW FIT`, `WINDOW <factor>`, `WINDOW (x1 y1)(x2 y2)`, `DISPLAY`,+`RATSNEST`, `GRID`. `fusion_execute_text_command` runs `Electron.*`: `SelectbyName`, `LeftButtonDown` /+`Select` x y (+ `ContinueSelect` / `EndSelect` for a marquee), `ImmediateRepaint /full`,+`sch_get_deviceinfo`. Reach for these only when a first-class verb doesn't cover what you need.++### Capture without disrupting the user+Record with WGC (`desktop_record_window_start`) - background, no focus steal, and it grabs the LIVE EAGLE+GL canvas. PrintWindow (`desktop_screenshot_window`) shows the chrome but does NOT reflect the GL view+changing, so verify motion by the verb's `ok` + a WGC frame, not a PrintWindow pixel-diff. A black canvas+means an empty/wrong file - see section 1.
skills/fusion-onboarding/SKILL.mdadded+81
@@ -0,0 +1,81 @@+---+name: fusion-onboarding+description: Onboard a brand-new Adom user who wants Fusion 360 features but doesn't have Fusion installed — the AI downloads the Fusion installer, silent-installs it, drives the Autodesk login (free/trial is enough), and auto-installs the AdomBridge add-in so the bridge can inject commands. "Complete the circuit" so fusion_* verbs work end to end. Trigger words — install fusion, no fusion, fusion not installed, set up fusion, download fusion, onboard fusion, get fusion, fusion trial, install the bridge add-in, fusion addin not running, set up the fusion bridge from scratch.+---++# Fusion 360 onboarding — from zero to a working bridge++Goal: a new Adom user with **no Fusion 360** should be able to use the Fusion bridge after a+hands-off, AI-driven setup. The AI installs Fusion, signs them in, and wires up the add-in so+`fusion_*` commands work. **The free/trial tier is plenty** for viewing files and a lot of real+work — nobody has to pay to get value.++All laptop steps run via `adom-desktop --target <laptop> shell_execute` / `run_script` /+`send_files`. Drive the user's own machine; don't ask them to do steps you can do.++## Step 0 — Detect that Fusion is missing+`fusion_start` (or `fusion_addin_status`) returns `errorCode: fusion_not_installed`, or+`fusion_detect` shows `installed:false`. That's the trigger to onboard.++## Step 1 — Download the Fusion installer+The installer is the small "Fusion Client Downloader" (it streams the full app during install).+```+URL: https://dl.appstreaming.autodesk.com/production/installers/Fusion Client Downloader.exe+```+Download it to a no-space path on the laptop (e.g. C:\tmp\):+```+shell_execute {"command":"curl -L -o C:\\tmp\\FusionDL.exe \"https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe\""}+```+(PowerShell `Invoke-WebRequest` works too; curl ships on modern Windows.)++## Step 2 — Silent install (elevated)+```+"C:\tmp\FusionDL.exe" --globalinstall --quiet      # system-wide, no UI+```+- Needs **admin/elevation** — run it so it elevates (UAC). On the user's own box this is one+  consent. It then **streams ~several GB**, so it can take 10-30 min — **monitor, don't block**+  (poll for the FusionLauncher under `%LOCALAPPDATA%\Autodesk\webdeploy\production\<hash>\`).+- Uninstall (if ever needed): `--globalinstall --process uninstall --quiet`.+- When done, `fusion_detect` flips to `installed:true` (the bridge finds the newest webdeploy+  launcher by mtime).++## Step 3 — First launch + Autodesk login (free account / trial)+`fusion_start` launches it; the first run needs an Autodesk login. Drive it exactly like the+**`fusion-aps-signin`** skill (open a NEW window, fetch any email OTP via `adom-google`, handle+"Continue with Google", etc.). If they have no Autodesk account, the sign-in page has **Create+account** (free) — and Fusion offers a **free trial / personal-use** tier that's enough for+viewing and a lot of work. Don't auto-enter their password; do drive the OTP/forms.++## Step 4 — Complete the circuit: auto-install the AdomBridge add-in+This is what lets the bridge inject commands into Fusion after launch.+- **The bridge does this for you:** on first dispatch, `server.py` auto-runs `install_addin` if+  the add-in isn't present (copies `addin/AdomBridge/` → Fusion's `API/AddIns/`). You can also+  force it: run the cache's `install_addin.py` (`python <bridges-cache>/fusion360/install_addin.py`).+- **It auto-loads every launch:** `AdomBridge.manifest` has **`"runOnStartup": true`**, so Fusion+  runs the add-in (and its localhost:8774 HTTP server) automatically on startup — **no manual+  "enable" needed**. (Verified: after a Fusion restart the add-in comes back `addinReady:true`+  with no UI interaction.)+- **If the add-in was just installed while Fusion was running**, restart Fusion (`fusion_close`+  then `fusion_start`) so it picks up + runs the add-in.++## Step 5 — Verify the circuit+```+fusion_addin_status {}          # add-in loaded + healthy?+fusion_get_app_state {}         # round-trips through the add-in to Fusion+```+When `fusion_start` returns `addinReady:true` and a `fusion_*` verb round-trips, the circuit is+complete — the bridge can drive Fusion (open/export/search/etc.).++## Then: cloud search (optional, recommended)+If they'll use cloud-file search, continue with the **`fusion-aps-search`** /+**`fusion-aps-signin`** skills (one-time-per-company APS setup; each user signs in once; never+charged).++## Notes / gotchas+- The big install is the slow part — **monitor** it (poll for the webdeploy launcher), don't sit+  on a blocking call.+- Admin elevation is required for `--globalinstall`; expect one UAC consent on the user's box.+- After a major Fusion self-update (Fusion web-deploys updates ~weekly), the add-in survives in+  `API/AddIns`, but re-verify with `fusion_addin_status`; re-run `install_addin` if it drifted.+- Verify the current installer URL / flags if Autodesk changes them (Sources: silentinstallhq,+  Autodesk deploy docs).
start.batadded+7
@@ -0,0 +1,7 @@+@echo off+REM Adom Fusion 360 Bridge Server launcher+REM Usage: start.bat [--port 8773]++cd /d "%~dp0"+python server.py %*+pause