app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
panel, export_3d, import_step with the board, edit_pours, tool_update
11 files changed
+362−17
CHANGELOG.md+17@@ -3,6 +3,23 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.13.32 (2026-10-30)++More of the standard answered exactly (eda-bridge-standard#29, "we need the verbs"):+- `fusion_panel` (the standard's panel, no longer not_supported): Fusion's docked panels (Browser, Inspector, Display+ Layers, Selection Filter, Design Manager, Place Components, Errors, Bill of Materials, Text Commands ...) answer its+ Panels.* text commands: Panels.List names them, Panels.Expose opens one, Panels.ValidateState proves it is expanded.+- `fusion_export_3d {format}`: one verb for the board's 3D model (stl, obj, glb, 3mf, fbx, iges, sat, usdz, f3d, skp,+ step); the 3D PCB is brought forward first, and a format Fusion cannot write is refused by name.+- `fusion_import_step` places the STEP with the board: imported into the design's 3D PCB as a new component at `at`+ and `rotation`, so bodies and height_check see it. `into:'new'` keeps the old new-design form.+- `fusion_change_pours` (and `fusion_edit_pours`, the standard's name) also sets thermals, rank and orphans, takes the+ standard's names (minWidthMm, clearanceMm, priority, keepIslands), and names a pour by net and layer when it is the+ only one there. Moving a pour to another net is refused: a polygon belongs to its signal.+- `fusion_tool_update {action: check | apply | auto}`: check reads Fusion's update offer without clicking anything.+- `selection` stays not_supported, now with three live checks: a ULP's ingroup(), ui.activeSelections after a SELECT,+ and the electron Board's selectionSets (InternalValidationError) all read nothing.+ ## 1.13.31 (2026-10-30) - Engine 0.23.0 (standard 0.28.0). `fusion_schematic_review` (and `board_report`) send the sheetDrawing companion, so
bridge.json+4@@ -111,6 +111,7 @@ "fusion_eagle_import_status", "fusion_edit_footprint", "fusion_edit_parts",+ "fusion_edit_pours", "fusion_electron_pan", "fusion_electron_run", "fusion_electron_select",@@ -118,6 +119,7 @@ "fusion_electronics_query", "fusion_estimate_cost", "fusion_execute_text_command",+ "fusion_export_3d", "fusion_export_3mf", "fusion_export_board_image", "fusion_export_bom",@@ -227,6 +229,7 @@ "fusion_open_electronics", "fusion_open_lbr", "fusion_open_schematic",+ "fusion_panel", "fusion_panelize", "fusion_part_parameters", "fusion_physical_properties",@@ -364,6 +367,7 @@ "fusion_teardrops", "fusion_test_board", "fusion_thermal_vias",+ "fusion_tool_update", "fusion_uninstall", "fusion_update_board_from_eagle", "fusion_update_footprints_from_library",
contracts/describe.json+116−6@@ -1090,12 +1090,12 @@ "boardId": "required", "expectDocument": "required exact board name", "expectedRevision": "required",- "pours": "required [{net, layer, points:[[x,y]...], width? (mm), isolate? (mm)}]"+ "pours": "required [{net, layer, points?:[[x,y]...] (omit when the net has one pour on that layer), width? or minWidthMm? (mm), isolate? or clearanceMm? (mm), thermals? (bool, or {on}), rank? or priority? (1-6), orphans? or keepIslands? (bool)}]" }, "longRunning": false, "name": "fusion_change_pours", "output": {- "changes": "[{net,layer,width:[before,after],isolate:[before,after],filledMm2:[before,after]}]",+ "changes": "[{net,layer,width,isolate,thermals,rank,orphans:[before,after],filledMm2:[before,after]}]", "revision": "str", "success": "bool", "verified": "bool"@@ -1936,6 +1936,35 @@ "summary": "Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. Fusion keeps board and schematic in step, so the edit is in the schematic too. dnp, excludeFromBom, excludeFromPos and mount are refused per part (Fusion keeps them in assembly variants).", "timeoutSeconds": 150 },+ {+ "example": {+ "pours": [+ {+ "clearanceMm": 0.3,+ "layer": 16,+ "net": "GND",+ "thermals": true+ }+ ]+ },+ "input": {+ "boardId": "required",+ "expectDocument": "required exact board name",+ "expectedRevision": "required",+ "pours": "required [{net, layer, points?, minWidthMm?, clearanceMm?, thermals?, priority?, keepIslands?}]"+ },+ "longRunning": false,+ "name": "fusion_edit_pours",+ "output": {+ "changes": "[{net,layer,width,isolate,thermals,rank,orphans:[before,after],filledMm2:[before,after]}]",+ "revision": "str",+ "success": "bool",+ "verified": "bool"+ },+ "statusVerb": "fusion_routing_state",+ "summary": "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",+ "timeoutSeconds": 120+ }, { "example": { "x1": 0,@@ -2098,6 +2127,29 @@ "summary": "Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included). Electron.* commands need an ELECTRONICS document context (schematic/board view active). Guard risky calls with expectDocument.", "timeoutSeconds": 60 },+ {+ "example": {+ "format": "stl",+ "outputPath": "C:/out/board.stl"+ },+ "input": {+ "expectDocument": "the design's name",+ "format": "required: stl|obj|glb|3mf|fbx|iges|sat|usdz|f3d|skp|step",+ "outputPath": "required: the file on the Fusion machine",+ "web": "bool (glb): optimized for a web viewer, default true"+ },+ "longRunning": true,+ "name": "fusion_export_3d",+ "output": {+ "bytes": "int",+ "components": "bool (always true: the 3D PCB holds the board and its parts)",+ "format": "str",+ "path": "str"+ },+ "statusVerb": null,+ "summary": "The board's 3D model in one format (the standard's export_3d): the design's 3D PCB is brought forward and Fusion's own exporter for the format writes the file. stl, obj, 3mf, fbx, iges, sat, usdz, f3d, skp and step; glb through the same optimizer as fusion_export_optimized_glb (web:false leaves it uncompressed). A format Fusion has no exporter for (vrml, ply, brep, ...) is refused by name, never faked.",+ "timeoutSeconds": 300+ }, { "example": { "outputPath": "C:/out/part.3mf"@@ -3119,19 +3171,33 @@ }, { "example": {- "filePath": "C:/Temp/part.step"+ "at": [+ 0,+ 0,+ -3+ ],+ "filePath": "C:/mech/enclosure.step" }, "input": {- "filePath": "required str (WINDOWS path C:/...; stage from container first)"+ "at": "[x, y, z] mm from the board origin, default [0, 0, 0]",+ "expectDocument": "the design's name",+ "filePath": "required str (the STEP on the Fusion machine)",+ "into": "'board' (default) or 'new'",+ "name": "the component's name (default the file's stem)",+ "rotation": "[rx, ry, rz] degrees, default [0, 0, 0]" }, "longRunning": true, "name": "fusion_import_step", "output": {- "documentName": "str",+ "bboxMm": "[x0, y0, z0, x1, y1, z1]",+ "bodies": "int",+ "documentsOpened": "int (0 when placed with the board)",+ "id": "the occurrence's path",+ "name": "str", "success": "bool" }, "statusVerb": null,- "summary": "Import a STEP/.stp (or other native-importable) file into a NEW Fusion design via the native import API. The open-electronics hints redirect .step here. (Was implemented but missing from this catalog until the verb suite's reverse-drift check caught it, 2026-08-14.)",+ "summary": "Bring a STEP in as 3D geometry placed with the board (the standard's import_step): the design's 3D PCB is brought forward and the STEP is imported into it as a new component (an enclosure, a bracket, a mating part) at `at` mm and `rotation` degrees from the board origin, so fusion_bodies and fusion_height_check measure the board against it. A STEP that imports nothing leaves no component behind. into:'new' opens the STEP as its own new Fusion design instead (the earlier behaviour). Save the design afterwards to keep it.", "timeoutSeconds": 300 }, {@@ -19700,6 +19766,28 @@ "summary": "Open a LOCAL legacy EAGLE .sch in Fusion's Schematic Editor (dialog-free Document.newDesignFromLocal; a background open). NAV RULE: the schematic, 2D board and 3D board are VIEWS of ONE electronics design, not standalone files. For a team design open the DESIGN document (fusion_open_cloud_file / fusion_open_by_urn) and switch views with fusion_show_schematic; this verb is for a .sch that exists on the Fusion machine (push it there first). Drive the opened sheet with fusion_electron_run (WINDOW FIT, DISPLAY ALL). The bridge captures a post-open screenshot because Fusion can raise a blocking dialog the API cannot see.", "timeoutSeconds": 60 },+ {+ "example": {+ "name": "Inspector"+ },+ "input": {+ "name": "the panel's title (case does not matter; part of it is enough); omitted, the list"+ },+ "longRunning": false,+ "name": "fusion_panel",+ "output": {+ "docked": "bool",+ "elapsedSec": "float",+ "lane": "already | panels_expose",+ "panel": "str",+ "panels": "[title] (no name)",+ "selected": "bool",+ "verified": "bool"+ },+ "statusVerb": null,+ "summary": "Open one of Fusion's docked panels (the standard's panel), or list them: Browser, Inspector, Display Layers, Selection Filter, Design Manager, Place Components, Errors, Bill of Materials, Text Commands, Comments ... Fusion's Panels.List names them, Panels.Expose opens one and Panels.ValidateState proves it is expanded. Nothing takes the foreground.",+ "timeoutSeconds": 30+ }, { "example": { "cols": 3,@@ -22957,6 +23045,28 @@ "summary": "A thermal via grid in a part's exposed pad (the standard's thermal_vias), planned by the shared checks engine: centred, inset from the pad's edge, clear of vias already there, sized by size/drill or the board's via rule, on the pad's net. Fusion places it, reads it back and undoes it on a mismatch; dryRun returns the plan with apply as fusion_place_vias arguments.", "timeoutSeconds": 180 },+ {+ "example": {+ "action": "check"+ },+ "input": {+ "action": "check (default) | apply | auto",+ "on": "bool (auto): turn automatic updating on or off; omitted, it is read"+ },+ "longRunning": false,+ "name": "fusion_tool_update",+ "output": {+ "applied": "bool",+ "auto": "bool",+ "current": "Fusion's version",+ "needsRestart": "bool",+ "offered": "the offer's button, or null",+ "updateOffered": "bool"+ },+ "statusVerb": null,+ "summary": "Fusion's own updates (the standard's tool_update): check reads the update Fusion offers (nothing clicked), apply takes it in the background the way the readiness gate does (deferred while Fusion is busy, never over unsaved work), auto reads or sets automatic updating (on by default).",+ "timeoutSeconds": 120+ }, { "example": { "dryRun": true
describe.py+17−3@@ -657,8 +657,11 @@ _T = [ {"expectDocument":"required exact board name","outputDir":"optional folder on this machine for bom.csv + bom-jlcpcb.csv"}, {"lines":"[{designators,quantity,value,package,mpn,manufacturer,mouser,digikey,lcsc}]","csv":"str","jlcpcbCsv":"str","files":"{name:path}","boardOnly":"[str]","missingPartNumbers":"{mouser,digikey,lcsc:[str]}"},60,"fusion_routing_state",False,{}), ("fusion_change_pours", "Change existing pours in place: the pen width (the narrowest copper the fill can reach; a pen wider than a throat leaves it empty) and the isolation. Each pour is named by net, layer and outline, picked on the midpoint of its longest edge, then RATSNEST refills. The readback proves each pour took its new width/isolate and that no track or via changed, and reports the filled area before and after.",- {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"required [{net, layer, points:[[x,y]...], width? (mm), isolate? (mm)}]"},- {"success":"bool","verified":"bool","changes":"[{net,layer,width:[before,after],isolate:[before,after],filledMm2:[before,after]}]","revision":"str"},120,"fusion_routing_state",False,{}),+ {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"required [{net, layer, points?:[[x,y]...] (omit when the net has one pour on that layer), width? or minWidthMm? (mm), isolate? or clearanceMm? (mm), thermals? (bool, or {on}), rank? or priority? (1-6), orphans? or keepIslands? (bool)}]"},+ {"success":"bool","verified":"bool","changes":"[{net,layer,width,isolate,thermals,rank,orphans:[before,after],filledMm2:[before,after]}]","revision":"str"},120,"fusion_routing_state",False,{}),+ ("fusion_edit_pours", "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",+ {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"required [{net, layer, points?, minWidthMm?, clearanceMm?, thermals?, priority?, keepIslands?}]"},+ {"success":"bool","verified":"bool","changes":"[{net,layer,width,isolate,thermals,rank,orphans:[before,after],filledMm2:[before,after]}]","revision":"str"},120,"fusion_routing_state",False,{"pours":[{"net":"GND","layer":16,"clearanceMm":0.3,"thermals":True}]}), ("fusion_board_seed", "Write the design a new board starts from, as EAGLE source on this machine: the board outline, the fab's design rules (Fusion's own rules block with your clearance, edge distance, minimum width and drill, hole-to-hole, pad and via restring and copper weight; Fusion's defaults are 0.35 mm drill, 1 mm to the edge and a 25 % pad restring, wrong for most fabs), the fixed parts at their places with their nets, and the library they come from. Then fusion_new_electronics_from_eagle imports it as a linked schematic and board.", {"libraryPath":"required .lbr on this machine","outDir":"required folder on this machine","name":"optional design name (letters, digits, _ -)","title":"optional sheet title","outlineMm":"required [width, height]","fixed":"optional [{ref, deviceset, value?, x, y (mm, board frame), rot?, nets:{pin:net}}]","rules":"optional {clearance, edge, minWidth, minDrill, holeToHole, padRestringRatio, padRestring, viaRestring, copperUm}"}, {"schPath":"str","brdPath":"str","fixedParts":"int"},60,"fusion_get_app_state",False,{}),@@ -883,7 +886,9 @@ _T = [ # ── CAD & 3D export ──────────────────────────────────────────────────── ("fusion_export_step", "Export the active design to STEP (needs the 3D view).", {"outputPath": "required str"}, {"format": "str", "fileSizeKB": "int"}, 300, None, True, {"outputPath": "C:/out/board.step"}),- ("fusion_import_step", "Import a STEP/.stp (or other native-importable) file into a NEW Fusion design via the native import API. The open-electronics hints redirect .step here. (Was implemented but missing from this catalog until the verb suite's reverse-drift check caught it, 2026-08-14.)", {"filePath": "required str (WINDOWS path C:/...; stage from container first)"}, {"success": "bool", "documentName": "str"}, 300, None, True, {"filePath": "C:/Temp/part.step"}),+ ("fusion_import_step", "Bring a STEP in as 3D geometry placed with the board (the standard's import_step): the design's 3D PCB is brought forward and the STEP is imported into it as a new component (an enclosure, a bracket, a mating part) at `at` mm and `rotation` degrees from the board origin, so fusion_bodies and fusion_height_check measure the board against it. A STEP that imports nothing leaves no component behind. into:'new' opens the STEP as its own new Fusion design instead (the earlier behaviour). Save the design afterwards to keep it.",+ {"filePath": "required str (the STEP on the Fusion machine)", "at": "[x, y, z] mm from the board origin, default [0, 0, 0]", "rotation": "[rx, ry, rz] degrees, default [0, 0, 0]", "name": "the component's name (default the file's stem)", "expectDocument": "the design's name", "into": "'board' (default) or 'new'"},+ {"success": "bool", "id": "the occurrence's path", "name": "str", "bodies": "int", "bboxMm": "[x0, y0, z0, x1, y1, z1]", "documentsOpened": "int (0 when placed with the board)"}, 300, None, True, {"filePath": "C:/mech/enclosure.step", "at": [0, 0, -3]}), # ── Catalog completion (2026-08-14): 14 verbs that were LIVE but undescribed, caught by # the verb suite's reverse drift check. An undescribed verb is invisible to ab's Verbs@@ -931,10 +936,19 @@ _T = [ ("fusion_set_window_sizing", "Read/toggle auto-sizing Fusion's WINDOW after launch (STICKY, default ON). Fusion opens at whatever geometry it last had, which after an automated launch is often a small default; the bridge grows it to ~92% of the monitor work area, centered, using SetWindowPos with NOACTIVATE (never ShowWindow/maximize) so focus is never stolen. A window the user maximized, or already sized to >=60% of the work area, is left alone. No args = read.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "sizeFusionWindow": "bool"}, 15, "fusion_state", False, {}), ("fusion_set_driving_badge", "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", {"enabled": "optional bool (omit to read)", "viewport": "optional str: \"native\" = only the frame Fusion itself renders (activeViewport.saveAsImageFile through the add-in), no window capture", "nativeViewport": "optional bool (default true with screenshot): also export the native viewport frame and count bodies; the reply carries nativeViewport {path, ok, bodies, productType}, geometry {bodies} (API truth, independent of pixels) and windowPixels {dominantColorFraction, blank} for the window capture. A blank window capture of a Design viewport is a known WGC artefact: trust nativeViewport, never foreground Fusion for a capture (issue #84)"}, {"success": "bool", "showDrivingBadge": "bool"}, 15, "fusion_state", False, {}), ("fusion_resolve_seat_dialog", "Readiness EXECUTOR (Rust port, Phase 3): detect + resolve the Autodesk seat/licensing modal ('Active Sessions Exceeded' / 'Suspend Remote Session') in the background, at most 3 UIA clicks per call, screenshot-verified. Idempotent: nothing up answers clicks:0. fusion_readiness calls this itself when it sees the dialog; call it directly only when you are driving the launch by hand.", {"maxClicks": "optional int (default 3)"}, {"clicks": "int", "resolved": "bool"}, 60, None, False, {}),+ ("fusion_tool_update", "Fusion's own updates (the standard's tool_update): check reads the update Fusion offers (nothing clicked), apply takes it in the background the way the readiness gate does (deferred while Fusion is busy, never over unsaved work), auto reads or sets automatic updating (on by default).",+ {"action": "check (default) | apply | auto", "on": "bool (auto): turn automatic updating on or off; omitted, it is read"},+ {"current": "Fusion's version", "offered": "the offer's button, or null", "updateOffered": "bool", "applied": "bool", "auto": "bool", "needsRestart": "bool"}, 120, None, False, {"action": "check"}),+ ("fusion_panel", "Open one of Fusion's docked panels (the standard's panel), or list them: Browser, Inspector, Display Layers, Selection Filter, Design Manager, Place Components, Errors, Bill of Materials, Text Commands, Comments ... Fusion's Panels.List names them, Panels.Expose opens one and Panels.ValidateState proves it is expanded. Nothing takes the foreground.",+ {"name": "the panel's title (case does not matter; part of it is enough); omitted, the list"},+ {"panels": "[title] (no name)", "panel": "str", "selected": "bool", "verified": "bool", "lane": "already | panels_expose", "docked": "bool", "elapsedSec": "float"}, 30, None, False, {"name": "Inspector"}), ("fusion_apply_fusion_update", "Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). Never the exit-class buttons while a host op is active. Answers {applied, button, why}.", {}, {"applied": "bool", "button": "str|null", "why": "str"}, 60, None, False, {}), ("fusion_set_seat_takeover", "Read/toggle the mid-session Autodesk seat-takeover sweep (STICKY per user, default ON). When Autodesk pops 'Active Sessions Exceeded' mid-session, the bridge clicks Continue through BOTH dialogs in the background: this machine takes the seat, the other machine's Fusion suspends RESUMABLY, the user gets one toast. No args = read current value. Gates only the passive sweep - a verb-driven fusion_start still grabs the seat (the user asked for Fusion, so the seat is implied).", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "autoTakeOverSeat": "bool"}, 15, "fusion_readiness", False, {}), ("fusion_state", "The RENDER-ME verb (kicad_state parity, issues #28/#30): running + window + dialog + busy state PLUS a fresh background screenshot of whatever Fusion is showing - the one verb that makes every background fusion_show_* result visible. SERVER-SIDE ONLY by design: it answers even while the add-in main thread is busy or wedged (activeDocument is best-effort with a short budget and skipped when busy). blockingDialogs carries the CLASSIFIED modal list (cause + resolution). imageAs:base64 inlines the PNG (auto-refused over 4MB - pull the screenshot path via pull_file instead).", {"screenshot": "optional bool (default true)", "imageAs": "optional str path|base64 (default path)"}, {"success": "bool", "running": "bool", "activeDocument": "str", "mainThreadBusy": "dict|null", "blockingDialogs": "list", "focusEvents": "list - the QUIETNESS AUDIT LOG (SDK canonical): every time this bridge took the user's foreground, or refused a bare foreground:true, as {at, verb, outcome: foreground|refused, reason}. Audit how quiet the bridge actually was instead of trusting that it was.", "timings": "dict - measured launch history on THIS machine (p50/p90 per phase, cold vs warm)", "screenshot": "str path", "screenshotBase64": "str (imageAs:base64 only)"}, 60, None, False, {}), ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}),+ ("fusion_export_3d", "The board's 3D model in one format (the standard's export_3d): the design's 3D PCB is brought forward and Fusion's own exporter for the format writes the file. stl, obj, 3mf, fbx, iges, sat, usdz, f3d, skp and step; glb through the same optimizer as fusion_export_optimized_glb (web:false leaves it uncompressed). A format Fusion has no exporter for (vrml, ply, brep, ...) is refused by name, never faked.",+ {"format": "required: stl|obj|glb|3mf|fbx|iges|sat|usdz|f3d|skp|step", "outputPath": "required: the file on the Fusion machine", "web": "bool (glb): optimized for a web viewer, default true", "expectDocument": "the design's name"},+ {"path": "str", "format": "str", "bytes": "int", "components": "bool (always true: the 3D PCB holds the board and its parts)"}, 300, None, True, {"format": "stl", "outputPath": "C:/out/board.stl"}), ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}), ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}), ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.usdz"}),
rust/bridge/src/appverbs.rs+51@@ -129,10 +129,61 @@ pub fn list_installed_libraries(args: &Value, pass: &dyn Fn(&str, Value) -> Valu else { "Cut short (budgetSeconds or a folder listing timed out): pass projectName to walk one project, or a larger budgetSeconds." }}) } +/// `fusion_panel {name?}`: the standard's panel in Fusion's terms. Fusion's docked panels+/// (Browser, Inspector, Display Layers, Selection Filter, Design Manager, Place Components, Errors,+/// Bill of Materials, Text Commands, ...) answer its Panels.* text commands: Panels.List names them,+/// Panels.Expose opens one, and Panels.ValidateState <title> EXPANDED proves it is open (checked live+/// on Fusion 2706, 2026-10-30). No window takes the foreground.+fn panel_titles(pass: &dyn Fn(&str, Value) -> Value) -> Result<Vec<String>, Value> {+ let r = pass("execute_text_command", json!({"command": "Panels.List"}));+ if r["success"].as_bool() == Some(false) { return Err(r); }+ let text = r["data"]["output"].as_str().or(r["output"].as_str()).or(r["data"]["result"].as_str()).unwrap_or("").to_string();+ Ok(panel_titles_from(&text))+}++fn panel_titles_from(text: &str) -> Vec<String> {+ let mut out: Vec<String> = Vec::new();+ for l in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.ends_with(':')) {+ let l = l.trim_start_matches("All Panels:").trim();+ if !l.is_empty() && !out.iter().any(|o| o == l) { out.push(l.to_string()); }+ }+ out+}++fn quoted(title: &str) -> String { if title.contains(' ') { format!("\"{title}\"") } else { title.to_string() } }++pub fn panel(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let t0 = std::time::Instant::now();+ let titles = match panel_titles(pass) { Ok(t) => t, Err(e) => return e };+ let Some(want) = args["name"].as_str().or(args["id"].as_str()).map(|s| s.trim().to_string()) else {+ return json!({"success": true, "mutated": false, "panels": titles, "_hint": "Pass name (a title from panels, case does not matter) to open that panel."});+ };+ let w = want.to_uppercase();+ let Some(title) = titles.iter().find(|t| **t == w).or_else(|| titles.iter().find(|t| t.contains(&w))).cloned() else {+ return json!({"success": false, "errorCode": "panel_not_found", "error": format!("Fusion has no panel named {want}"), "panels": titles});+ };+ let state = |s: &str| pass("execute_text_command", json!({"command": format!("Panels.ValidateState {} {s}", quoted(&title))}))["success"].as_bool() == Some(true);+ let already = state("EXPANDED");+ if !already {+ let r = pass("execute_text_command", json!({"command": format!("Panels.Expose {}", quoted(&title))}));+ if r["success"].as_bool() == Some(false) { return json!({"success": false, "errorCode": "panel_not_selected", "error": r["error"], "panel": title}); }+ }+ let verified = already || state("EXPANDED");+ json!({"success": verified, "mutated": !already, "panel": title, "selected": verified, "verified": verified, "lane": if already { "already" } else { "panels_expose" },+ "docked": state("DOCKED"), "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,+ "errorCode": if verified { Value::Null } else { json!("panel_not_selected") }})+}+ #[cfg(test)] mod tests { use super::*; #[test]+ fn panel_titles_come_from_panels_list() {+ let t = panel_titles_from("All Panels: \nBROWSER\nINSPECTOR\nDISPLAY LAYERS\nAUTODESK ASSISTANT\nAUTODESK ASSISTANT\n");+ assert_eq!(t, vec!["BROWSER", "INSPECTOR", "DISPLAY LAYERS", "AUTODESK ASSISTANT"]);+ assert_eq!(quoted("DISPLAY LAYERS"), "\"DISPLAY LAYERS\"");+ }+ #[test] fn credential_names_are_never_shown() { assert!(secret("ADOM_API_KEY") && secret("ADOM_SESSION") && secret("FUSION_AUTH_TOKEN") && secret("password_file")); assert!(!secret("ADOM_AI_THREAD") && !secret("LOCALAPPDATA"));
rust/bridge/src/main.rs+4−3@@ -80,7 +80,7 @@ const STANDARD_ALIASES: &[(&str, &str)] = &[ ("status", "fusion_readiness"), ("save", "fusion_save_document"), ("run_script", "fusion_run_modeling_script"), ("run_command", "fusion_execute_text_command"), ("list_commands", "fusion_list_text_commands"), ("dialog_log", "fusion_action_ledger"), ("signin_status", "fusion_account"), ("license_status", "fusion_account"), ("open_document", "fusion_open_design"),- ("show_3d_chip", "fusion_show_3d_package"),+ ("show_3d_chip", "fusion_show_3d_package"), ("edit_pours", "fusion_change_pours"), ]; /// The standard's verbs this bridge serves through its own, with the arguments renamed:@@ -565,10 +565,10 @@ fn main() { r["shell"] = json!("rust"); r }, "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+ v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_export_3d" | "export_3d" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&parsed["args"], &pass) }, "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package" | "fusion_import_part" | "import_part") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), "import_part" => partimport::import_part(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),+ v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_tool_update" | "tool_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "tool_update" => maintenance::tool_update(&ctx, &parsed["args"], &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass), "signin" => { let readiness = || owned_readiness(&**h, &sidecar, &caller); let start = || launch::start(&ctx, &json!({})); let aps_state = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); signin::signin(&ctx, &parsed["args"], &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin }) }, "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]), "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "narrate" => tour::narrate(&parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) },@@ -698,6 +698,7 @@ fn main() { | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern" | "fusion_set_models" | "set_models" | "fusion_add_models" | "add_models" | "fusion_symbol_prims" | "symbol_prims" | "fusion_set_visibility" | "set_visibility" | "fusion_add_symbol_art" | "add_symbol_art" | "fusion_lint_library" | "lint_library" | "fusion_plate_vias" | "plate_vias" | "fusion_cleanup_libs" | "cleanup_libs") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]), "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]), "fusion_not_supported" | "not_supported" => unsupported::answer(&parsed["args"]),+ "fusion_panel" | "panel" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let mut r = appverbs::panel(&parsed["args"], &pass); r["shell"] = json!("rust"); r }, // Native since Phase 2: the raw authenticated APS GET (never-charge guarded). "fusion_aps_get" | "aps_get" => native_aps_get(&parsed["args"]), // Native since Phase 2: cloud discovery on the Data Management API.
rust/bridge/src/maintenance.rs+31@@ -186,3 +186,34 @@ pub fn prefs_close(ctx: &Ctx, args: &Value) -> Value { std::thread::sleep(Duration::from_secs(2)); json!({"success": true, "closed": prefs_dialog_hwnd(&ab, &ctx.caller).is_none(), "saved": save, "narrate": if save { "Saved your Fusion preferences." } else { "Closed preferences without saving." }, "_hint": if save { "Applied and closed. Some settings (the MCP server is one) only take effect once saved this way." } else { "Cancelled; nothing was changed." }, "statusVerb": "fusion_get_preferences"}) }++/// `fusion_tool_update {action}`: the standard's tool_update. check reads the update Fusion offers+/// (nothing is clicked), apply takes it the way the readiness gate does (in the background, deferred+/// while Fusion is busy), auto reads or sets automatic updating.+pub fn tool_update(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let auto = crate::small_verbs::get_pref(&ctx.state_dir, "autoUpdateFusion").map(|v| v != json!(false)).unwrap_or(true);+ let current = pass("get_app_state", json!({}))["data"]["version"].clone();+ match args["action"].as_str().unwrap_or("check") {+ "check" => {+ let offer = find_update_offer(ctx);+ let offered = offer["offered"].as_bool() == Some(true);+ json!({"success": true, "mutated": false, "action": "check", "current": current, "offered": if offered { offer["button"].clone() } else { Value::Null },+ "updateOffered": offered, "auto": auto, "applied": false, "needsRestart": false,+ "_hint": if offered { "Fusion offers an update: tool_update {action:'apply'} takes it in the background (deferred while Fusion is busy)." } else { "No update offered: Fusion is current, or Autodesk has not offered one in this session yet." }})+ }+ "apply" => {+ let mut r = apply_fusion_update(ctx, pass);+ let applied = r["applied"].as_bool() == Some(true);+ r["action"] = json!("apply"); r["current"] = current; r["auto"] = json!(auto); r["needsRestart"] = json!(applied);+ r["mutated"] = json!(applied);+ r+ }+ "auto" => {+ let a = if args["on"].is_boolean() { json!({"enabled": args["on"]}) } else { json!({}) };+ let mut r = crate::small_verbs::set_auto_update(&ctx.state_dir, &a);+ r["action"] = json!("auto"); r["auto"] = r["autoUpdateFusion"].clone(); r["current"] = current;+ r+ }+ other => json!({"success": false, "errorCode": "bad_args", "error": format!("action is check, apply or auto, not {other}")}),+ }+}
rust/bridge/src/routing_script.py+23−3@@ -236,6 +236,7 @@ def pours(board): filled+=a; islands+=1 out.append({'net':s.get('name'),'layer':int(p.get('layer','1')),'outline':outline, 'isolate':float(p.get('isolate','0')),'rank':int(p.get('rank','1')),'width':float(p.get('width','0')),+ 'thermals':p.get('thermals','yes')!='no','orphans':p.get('orphans','no')=='yes', 'filledMm2':round(filled,3),'islands':islands,'filled':fd is not None}) return out @@ -4010,10 +4011,24 @@ def handle(app,args,action): # the midpoint of its longest outline edge; the readback proves that pour changed and # no other copper did. want=args.get('pours')- if not isinstance(want,list) or not want: fail('invalid_args','pours must be a non-empty list of {net, layer, points, width?, isolate?}')+ if not isinstance(want,list) or not want: fail('invalid_args','pours must be a non-empty list of {net, layer, points, width?, isolate?, thermals?, rank?, orphans?}')+ # the standard's edit_pours names: minWidthMm, clearanceMm, thermals {on}, priority, keepIslands+ for q in want:+ if q.get('width') is None and q.get('minWidthMm') is not None: q['width']=q['minWidthMm']+ if q.get('isolate') is None and q.get('clearanceMm') is not None: q['isolate']=q['clearanceMm']+ if isinstance(q.get('thermals'),dict): q['thermals']=q['thermals'].get('on')+ if q.get('rank') is None and q.get('priority') is not None: q['rank']=q['priority']+ if q.get('orphans') is None and q.get('keepIslands') is not None: q['orphans']=q['keepIslands']+ if q.get('net') and q.get('newNet'): fail('not_supported',"A pour's net cannot be changed in place in Fusion (a polygon belongs to its signal): remove_pours it and apply_pours a new one on the other net")+ if q.get('rank') is not None and not (1<=int(q['rank'])<=6): fail('invalid_args','rank is 1 (poured first) to 6') have=pours(board); before_cu=copper_signature(objects(board)[2]+objects(board)[3]) targets=[] for q in want:+ if q.get('points') is None:+ # no outline: net and layer name the pour when that net has one pour on that layer+ same=[e for e in have if e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1))]+ if len(same)!=1: fail('unknown_pour' if not same else 'ambiguous_pour','%d %s pours on layer %s: give its points (pour_state lists each outline)'%(len(same),q.get('net'),q.get('layer')))+ q['points']=same[0]['outline'] pts=[[float(a),float(b)] for a,b in q.get('points',[])] hit=next((e for e in have if e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1)) and same_outline(e['outline'],pts)),None) if not hit: fail('unknown_pour','No %s pour on layer %s with that outline'%(q.get('net'),q.get('layer')))@@ -4023,6 +4038,9 @@ def handle(app,args,action): cmd='LAYER %d;'%hit['layer'] if q.get('width') is not None: cmd+=' CHANGE WIDTH %.6fmm (%.6fmm %.6fmm);'%(number(q['width'],True),*at) if q.get('isolate') is not None: cmd+=' CHANGE ISOLATE %.6fmm (%.6fmm %.6fmm);'%(number(q['isolate']),*at)+ if q.get('thermals') is not None: cmd+=' CHANGE THERMALS %s (%.6fmm %.6fmm);'%('ON' if q['thermals'] else 'OFF',*at)+ if q.get('rank') is not None: cmd+=' CHANGE RANK %d (%.6fmm %.6fmm);'%(int(q['rank']),*at)+ if q.get('orphans') is not None: cmd+=' CHANGE ORPHANS %s (%.6fmm %.6fmm);'%('ON' if q['orphans'] else 'OFF',*at) attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd); targets.append((q,hit)) app.executeTextCommand('Electron.run RATSNEST;') app.executeTextCommand('Electron.ImmediateRepaint /full')@@ -4030,8 +4048,10 @@ def handle(app,args,action): bad=[]; changes=[] for q,hit in targets: n=next((e for e in now if e['net']==hit['net'] and e['layer']==hit['layer'] and same_outline(e['outline'],hit['outline'])),None)- ok=n is not None and (q.get('width') is None or abs(n['width']-q['width'])<1e-4) and (q.get('isolate') is None or abs(n['isolate']-q['isolate'])<1e-4)- changes.append({'net':hit['net'],'layer':hit['layer'],'width':[hit['width'],n and n['width']],'isolate':[hit['isolate'],n and n['isolate']],'filledMm2':[hit['filledMm2'],n and n['filledMm2']]})+ ok=n is not None and (q.get('width') is None or abs(n['width']-q['width'])<1e-4) and (q.get('isolate') is None or abs(n['isolate']-q['isolate'])<1e-4) \+ and (q.get('thermals') is None or n['thermals']==bool(q['thermals'])) and (q.get('rank') is None or n['rank']==int(q['rank'])) and (q.get('orphans') is None or n['orphans']==bool(q['orphans']))+ changes.append({'net':hit['net'],'layer':hit['layer'],'width':[hit['width'],n and n['width']],'isolate':[hit['isolate'],n and n['isolate']],+ 'thermals':[hit['thermals'],n and n['thermals']],'rank':[hit['rank'],n and n['rank']],'orphans':[hit['orphans'],n and n['orphans']],'filledMm2':[hit['filledMm2'],n and n['filledMm2']]}) if not ok: bad.append(hit['net']) other=copper_signature(objects(after)[2]+objects(after)[3])!=before_cu okall=not bad and not other
rust/bridge/src/services.rs+92@@ -533,3 +533,95 @@ mod silk_tests { #[test] fn empty_capture_is_left_alone() { assert!(clean_silk_png(&enc(4, 4, &[0u8; 64])).is_none()); } }++/// `fusion_export_3d {format}`: the standard's export_3d, the board's 3D model in one format. The+/// design's 3D PCB is brought forward, then Fusion's own exporter for that format writes the file;+/// glb goes through the same optimizer as fusion_export_optimized_glb (web:false leaves it+/// uncompressed). A format Fusion has no exporter for is refused by name.+pub fn export_3d(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let format = args["format"].as_str().unwrap_or("").trim().trim_start_matches('.').to_lowercase();+ let Some(out) = args["outputPath"].as_str().map(str::to_string) else { return json!({"success": false, "errorCode": "bad_args", "error": "outputPath is required (the file on the Fusion machine)"}) };+ if args["components"].as_bool() == Some(false) {+ return json!({"success": false, "errorCode": "not_supported", "error": "components:false is not supported: Fusion's 3D PCB holds the board and its parts as one design, and its exporters write all of it"});+ }+ let verb = match format.as_str() {+ "step" | "stp" => "export_step", "stl" => "export_stl", "obj" => "export_obj", "3mf" => "export_3mf", "fbx" => "export_fbx",+ "iges" | "igs" => "export_iges", "sat" => "export_sat", "usdz" => "export_usdz", "f3d" => "export_f3d", "skp" => "export_skp", "glb" => "glb",+ "" => return json!({"success": false, "errorCode": "bad_args", "error": "format is required: stl, obj, glb, 3mf, fbx, iges, sat, usdz, f3d, skp or step"}),+ f => return json!({"success": false, "errorCode": "not_supported", "format": f,+ "error": format!("Fusion has no {f} exporter. It writes stl, obj, glb, 3mf, fbx, iges, sat, usdz, f3d, skp and step"),+ "_hint": "Pick one of those, or export step and convert it elsewhere."}),+ };+ let shown = pass("show_3d_board", json!({"expectDocument": args["expectDocument"]}));+ if shown["success"].as_bool() == Some(false) { return shown; }+ if verb == "glb" {+ let mut a = json!({"outputPath": out, "board": false, "gerbers": false});+ if args["web"].as_bool() == Some(false) { a["compress"] = json!("none"); }+ let mut r = export_optimized_glb(&a, pass);+ r["format"] = json!("glb");+ r["aliasOf"] = json!("fusion_export_optimized_glb");+ return r;+ }+ let mut r = pass(verb, json!({"outputPath": out}));+ let ok = r["success"].as_bool() != Some(false);+ let bytes = std::fs::metadata(&out).map(|m| m.len()).ok();+ r["format"] = json!(format);+ r["path"] = json!(out);+ r["components"] = json!(true);+ if let Some(b) = bytes { r["bytes"] = json!(b); }+ if ok && bytes.is_none() { r["success"] = json!(false); r["errorCode"] = json!("export_missing"); r["error"] = json!(format!("Fusion answered but {out} was not written")); }+ r+}++/// `fusion_import_step`'s board form: the STEP brought into the design's 3D PCB as a new component+/// (an enclosure, a bracket, a mating part), at `at` mm and `rotation` degrees from the board origin,+/// so height_check and the bodies companion can measure the board against it.+const IMPORT_STEP_SCRIPT: &str = r#"+import adsk.core, adsk.fusion, math, os+path = __PATH__+d = adsk.fusion.Design.cast(app.activeProduct)+if d is None:+ result = {'success': False, 'errorCode': 'no_3d_board', 'error': "The design's 3D PCB is not in front"}+elif not os.path.isfile(path):+ result = {'success': False, 'errorCode': 'file_not_found', 'error': 'No STEP at %s on the Fusion machine' % path}+else:+ at, rot = __AT__, __ROT__+ m = adsk.core.Matrix3D.create()+ for axis, deg in zip((adsk.core.Vector3D.create(1, 0, 0), adsk.core.Vector3D.create(0, 1, 0), adsk.core.Vector3D.create(0, 0, 1)), rot):+ if deg:+ r = adsk.core.Matrix3D.create(); r.setToRotation(math.radians(deg), axis, adsk.core.Point3D.create(0, 0, 0)); m.transformBy(r)+ m.translation = adsk.core.Vector3D.create(at[0] / 10.0, at[1] / 10.0, at[2] / 10.0)+ before = app.documents.count+ occ = d.rootComponent.occurrences.addNewComponent(m)+ occ.component.name = __NAME__+ opts = app.importManager.createSTEPImportOptions(path)+ opts.isViewFit = False+ got = app.importManager.importToTarget2(opts, occ.component)+ bodies = occ.component.bRepBodies.count + sum(o.component.bRepBodies.count for o in occ.component.allOccurrences)+ if got is None or got.count == 0 or bodies == 0:+ occ.deleteMe()+ result = {'success': False, 'errorCode': 'import_failed', 'error': 'Fusion imported nothing from %s into the 3D PCB (the component was removed again)' % path}+ else:+ b = occ.boundingBox+ result = {'success': True, 'mutated': True, 'id': occ.fullPathName, 'name': occ.component.name, 'bodies': bodies, 'documentsOpened': app.documents.count - before,+ 'bboxMm': [round(v * 10, 3) for v in (b.minPoint.x, b.minPoint.y, b.minPoint.z, b.maxPoint.x, b.maxPoint.y, b.maxPoint.z)]}+"#;++pub fn import_step_with_board(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let Some(path) = args["filePath"].as_str().or(args["path"].as_str()).map(|p| p.replace('\\', "/")) else { return json!({"success": false, "errorCode": "bad_args", "error": "filePath is required: the STEP on the Fusion machine"}) };+ let v3 = |k: &str| -> Vec<f64> { let a: Vec<f64> = args[k].as_array().map(|a| a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect()).unwrap_or_default(); if a.len() == 3 { a } else { vec![0.0; 3] } };+ let (at, rot) = (v3("at"), v3("rotation"));+ let name = args["name"].as_str().map(str::to_string).unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or("STEP".into()));+ let shown = pass("show_3d_board", json!({"expectDocument": args["expectDocument"]}));+ if shown["success"].as_bool() == Some(false) { return shown; }+ let script = IMPORT_STEP_SCRIPT.replace("__PATH__", &fusion_lbr::py_repr_str(&path)).replace("__NAME__", &fusion_lbr::py_repr_str(&name))+ .replace("__AT__", &format!("({}, {}, {})", at[0], at[1], at[2])).replace("__ROT__", &format!("({}, {}, {})", rot[0], rot[1], rot[2]));+ let r = pass("run_modeling_script", json!({"script": script}));+ if r["success"].as_bool() != Some(true) { return r; }+ let mut out = r["data"]["result"].clone();+ if !out.is_object() { return json!({"success": false, "errorCode": "import_failed", "error": "Fusion returned no structured result", "data": r["data"]}); }+ if out["success"] == json!(true) {+ out["_hint"] = json!("Placed with the board in its 3D PCB. fusion_bodies and fusion_height_check now measure the board against it; save the design (fusion_save_document) to keep it.");+ }+ out+}
rust/bridge/src/unsupported.rs+1−2@@ -25,8 +25,7 @@ pub const TABLE: &[(&str, &str)] = &[ ("connect_workspace", "Altium's workspace picker; Fusion works in the signed-in Autodesk account's hub (fusion_account)"), ("selftest_script_error", "a test of Altium's DelphiScript agent; Fusion runs none"), ("symbol_mode", "an EAGLE symbol has one look: there are no display modes to show"),- ("panel", "Altium's docked panel tabs; Fusion's editors show their own panels, and the bridge opens a surface (library, symbol, footprint, board, schematic, 3D) with fusion_show_* in the background"),- ("selection", "Fusion does not show the user's board selection to the bridge: a ULP's ingroup() reads no part even right after a GROUP (checked live on Fusion 2706, 2026-10-30). Name the parts instead; fusion_electron_select shows one to the user"),+ ("selection", "Fusion does not show the user's board selection to the bridge, checked live on Fusion 2706 (2026-10-30) three ways: a ULP's ingroup() reads no part even right after a GROUP, ui.activeSelections reads 0 right after a SELECT, and the electron Board's selectionSets raises InternalValidationError. Name the parts instead; fusion_electron_select shows one to the user"), ("uninstall_library", "a library Fusion offers is a file in the Autodesk hub, so taking it out means deleting that file for everyone in the hub, which the standard's uninstall never does (it leaves files alone); a hub file the AI made itself is deleted with fusion_cleanup_cloud_files"), ("normalize_bodies", "a Fusion footprint's 3D model is a cloud 3D package placed when it is made (fusion_make_3d_package and fusion_bind_library_3d orient it on the pads; fusion_show_3d_package places one by hand): a library holds no body offsets to move"), ("sch_sheet_symbol", "Fusion's hierarchy is modules, and a module cannot be made from a text command (EDIT 'name.m' answers nothing and the design gets no module, checked live on Fusion 2706); module instances are placed in Fusion's module dialog. fusion_sch_hierarchy reads an existing hierarchy and fusion_sch_sheet_pins its ports"),
tests/run_verb_tests.py+6@@ -434,6 +434,12 @@ for v in ("fusion_stop", "fusion_kill", "fusion_signin", "fusion_signin_2fa", "f t("fusion_action_ledger", "daily", {"limit": 5}, {"ok": True}) t("fusion_addin_status", "daily", {}, {"ok": True}) t("fusion_alerts", "daily", {"sinceSec": 300}, {"ok": True, "fields": ["alerts"]})+t("fusion_panel", "daily", {}, {"ok": True, "fields": ["panels"]}, "read-only: Fusion's docked panels (Panels.List)")+t("fusion_panel#inspector", "daily", {"name": "Inspector"}, {"ok": True, "fields": ["panel", "verified"]}, "Panels.Expose then Panels.ValidateState EXPANDED; no foreground")+t("fusion_tool_update", "daily", {"action": "check"}, {"ok": True, "fields": ["updateOffered", "auto"]}, "read-only: Fusion's update offer, nothing clicked")+t("fusion_export_3d#vrml", "negative", {"format": "vrml", "outputPath": f"{SCRATCH}/board.wrl"}, {"errorCode": "not_supported"}, "a format Fusion has no exporter for is refused by name")+t("fusion_export_3d", "daily", dict(B, format="stl", outputPath=f"{SCRATCH}/board-3d.stl"), {"ok": True, "fields": ["path", "bytes"]}, "the design's 3D PCB as STL through the one export_3d verb")+t("fusion_edit_pours", "manual", note="writes pours: tested live on lane-BQ25792-copy (thermals and rank set, read back, set back)") t("fusion_log_tail", "daily", {"sinceOffset": 0, "maxBytes": 20000, "errorsOnly": True}, {"ok": True, "fields": ["entries", "next", "truncated"]}, "read-only: Fusion's own log since an offset") t("fusion_verb_times", "daily", {"limit": 20}, {"ok": True, "fields": ["verbs", "since"]}) t("fusion_live_feed", "daily", {"action": "poll"}, {"ok": True}, "one poll of the feed; no waiting")